Skip to main content
RapidDev - Software Development Agency

How to Build a Music Streaming Backend with Lovable

Build a music streaming backend in Lovable by storing audio files in a private Supabase Storage bucket, generating short-lived signed URLs for streaming in an Edge Function, tracking listen history per user, building a custom audio player with resume support, and handling resumable uploads for large audio files.

What you'll build

  • Private Supabase Storage bucket with access controlled exclusively through Edge Function signed URLs
  • Edge Function that verifies user access rights and returns a short-lived signed URL for audio streaming
  • Custom HTML5 audio player with play, pause, seek bar, volume, and previous/next track controls
  • Tracks and playlists database schema with artist, album, genre, and duration metadata
  • Resumable upload flow using tus-js-client for large audio files with per-file progress tracking
  • Listen history table that records play events and enables resume-from-position across sessions
  • Artist dashboard for uploading tracks, managing playlists, and viewing play count analytics
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced18 min read4–6 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build a music streaming backend in Lovable by storing audio files in a private Supabase Storage bucket, generating short-lived signed URLs for streaming in an Edge Function, tracking listen history per user, building a custom audio player with resume support, and handling resumable uploads for large audio files.

What you're building

Audio files must never be served from a public URL in a music streaming app — public URLs can be downloaded freely, bypassing any subscription or access control. Supabase Storage private buckets solve this by requiring a signed URL for every access. The signed URL is time-limited (typically 60–300 seconds) and can only be fetched by making an authenticated request to your Edge Function.

The Edge Function acts as the gatekeeper: it receives a track_id, verifies the requesting user has the right to stream that track (subscriber, purchased, or free tier with play limits), calls the Supabase Storage API with the service role key to generate a signed URL, and returns only the URL to the client. The audio player receives this URL and streams the audio using a standard HTML5 Audio element.

Because signed URLs expire, the player requests a fresh URL before the current one expires. This is handled by setting a refresh timer that fires 30 seconds before expiry and fetches a new URL silently while playback continues uninterrupted.

Large audio files (lossless FLAC, high-bitrate MP3) are handled with resumable uploads using the tus protocol. If a 200MB upload is interrupted, the user can resume from where they left off rather than starting over.

Final result

A fully functional music streaming backend where artists upload tracks, listeners stream with a custom player, and all audio is protected behind authenticated signed URL access.

Tech stack

LovableFrontend
SupabaseDatabase, Auth, Storage, Edge Functions
Supabase StoragePrivate audio file storage
shadcn/uiUI Components
Tailwind CSSStyling

Prerequisites

  • Lovable Pro account (Edge Functions for signed URL generation need credits to implement correctly)
  • Supabase project created at supabase.com — free tier works but Pro ($25/mo) gives more Storage
  • Supabase URL, anon key, and service role key — service role key goes in Cloud tab → Secrets only
  • A few test audio files (MP3, FLAC, or M4A) for initial upload and playback testing
  • Optional: Supabase Storage Pro add-on for more than 1GB of audio file storage

Build steps

1

Define the music metadata schema and private bucket

The schema covers artists, albums, tracks, playlists, and listen history. The private Storage bucket stores audio files. Nothing in the schema exposes actual file paths to untrusted clients — those are only used inside the Edge Function.

prompt.txt
1Create a music streaming backend with Supabase. Set up these tables:
2
3- artist_profiles: id (references auth.users), display_name, bio, avatar_url, genre_tags (text[]), created_at
4- albums: id, artist_id (references artist_profiles), title, cover_art_url, release_year (int), genre (text), created_at
5- tracks: id, artist_id (references artist_profiles), album_id (references albums nullable), title, duration_seconds (numeric), file_size_bytes (int), mime_type (text), storage_path (text NEVER exposed to client), play_count (int default 0), track_number (int nullable), genre (text), is_published (bool default false), created_at
6- playlists: id, owner_id (references auth.users), title, description, cover_art_url, is_public (bool default true), created_at
7- playlist_tracks: id, playlist_id (references playlists), track_id (references tracks), position (int), added_at
8- listen_history: id, user_id (references auth.users), track_id (references tracks), position_seconds (numeric default 0), completed (bool default false), last_played_at unique(user_id, track_id)
9
10RLS:
11- artist_profiles: publicly readable, users manage their own
12- albums: publicly readable
13- tracks: SELECT only shows rows where is_published = true. storage_path MUST NOT be included in client queries never select it directly. Artists can INSERT/UPDATE/DELETE their own tracks
14- playlists: publicly readable if is_public = true, owners can CRUD their own
15- playlist_tracks: inherits playlist visibility
16- listen_history: users can only read/write their own rows
17
18Create a Supabase Storage bucket called 'audio':
19- PRIVATE bucket (no public URL access)
20- Allowed MIME types: audio/mpeg, audio/flac, audio/mp4, audio/ogg, audio/wav
21- Max upload size: 500MB

Pro tip: Add a Column Security Policy (using PostgreSQL column-level security or a database view) that hides storage_path from the public tracks view. Create a view tracks_public that selects all columns from tracks EXCEPT storage_path, and grant SELECT on this view to the anon role. The Edge Function uses the service role to access storage_path directly.

Expected result: All six tables are created with correct RLS. The audio bucket is set to private. TypeScript types are generated. A test confirms that selecting tracks from the anon client does not return the storage_path column.

2

Build the signed URL Edge Function

This Edge Function is the only component that can access audio files. It verifies the user's session, optionally checks subscription status, and returns a time-limited signed URL for the requested track.

prompt.txt
1Create a Supabase Edge Function at supabase/functions/get-stream-url/index.ts.
2
3Receives: { track_id: string } in body.
4Requires authentication (validate JWT).
5
6Logic:
71. Extract user_id from JWT.
82. Fetch track from Supabase using service role key:
9 SELECT id, storage_path, is_published, duration_seconds FROM tracks WHERE id = track_id AND is_published = true
10 If no row returned, return 404.
113. Optional access check (uncomment for subscription model):
12 Check if user has an active subscription in a subscriptions table. If not and the track requires subscription, return 403.
134. Generate a signed URL with expiry of 300 seconds (5 minutes):
14 const { data, error } = await supabaseAdmin.storage.from('audio').createSignedUrl(track.storage_path, 300)
155. If error generating signed URL, return 500.
166. Log the play event: upsert into listen_history { user_id, track_id, last_played_at: now() } ON CONFLICT (user_id, track_id) DO UPDATE SET last_played_at = now().
177. Increment play_count: UPDATE tracks SET play_count = play_count + 1 WHERE id = track_id.
188. Return: { signed_url: data.signedUrl, expires_in: 300, track_id, duration_seconds: track.duration_seconds }
19
20Store SUPABASE_SERVICE_ROLE_KEY in Cloud tab Secrets. Use it to initialize a separate admin Supabase client in the Edge Function.

Pro tip: Set the signed URL expiry to slightly longer than the track's duration_seconds plus 30 seconds of buffer. This means the URL never expires mid-playback for normal listening, and you only need to refresh it if the user pauses for an extended period.

Expected result: Calling the Edge Function from an authenticated client returns a signed URL. Pasting this URL in the browser plays the audio. Attempting to call with an invalid track_id returns 404. The listen_history row is created or updated.

3

Build the custom audio player with signed URL refresh

The audio player wraps an HTML5 Audio element with custom controls. When a track is selected, it fetches a signed URL from the Edge Function. A timer refreshes the URL before it expires to prevent playback interruption.

src/components/player/AudioPlayer.tsx
1import { useEffect, useRef, useState, useCallback } from 'react'
2import { supabase } from '@/integrations/supabase/client'
3import { Button } from '@/components/ui/button'
4import { Slider } from '@/components/ui/slider'
5import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
6
7type Track = { id: string; title: string; artist_name: string; duration_seconds: number; cover_art_url?: string }
8
9export function AudioPlayer({ track, onNext, onPrev }: { track: Track | null; onNext: () => void; onPrev: () => void }) {
10 const audioRef = useRef<HTMLAudioElement>(null)
11 const [playing, setPlaying] = useState(false)
12 const [currentTime, setCurrentTime] = useState(0)
13 const [volume, setVolume] = useState(0.8)
14 const [muted, setMuted] = useState(false)
15 const urlRefreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
16
17 const fetchAndLoad = useCallback(async (trackId: string, resumeAt?: number) => {
18 const { data, error } = await supabase.functions.invoke('get-stream-url', { body: { track_id: trackId } })
19 if (error || !data?.signed_url) return
20 const audio = audioRef.current
21 if (!audio) return
22 audio.src = data.signed_url
23 audio.currentTime = resumeAt ?? 0
24 audio.load()
25 if (playing) audio.play()
26 if (urlRefreshTimer.current) clearTimeout(urlRefreshTimer.current)
27 const refreshIn = Math.max((data.expires_in - 30) * 1000, 5000)
28 urlRefreshTimer.current = setTimeout(() => fetchAndLoad(trackId, audio.currentTime), refreshIn)
29 }, [playing])
30
31 useEffect(() => {
32 if (!track) return
33 supabase.from('listen_history').select('position_seconds').eq('track_id', track.id).single()
34 .then(({ data }) => fetchAndLoad(track.id, data?.position_seconds ?? 0))
35 return () => { if (urlRefreshTimer.current) clearTimeout(urlRefreshTimer.current) }
36 }, [track?.id])
37
38 useEffect(() => {
39 const audio = audioRef.current
40 if (!audio) return
41 const onTime = () => setCurrentTime(audio.currentTime)
42 const onEnded = () => { setPlaying(false); onNext() }
43 audio.addEventListener('timeupdate', onTime)
44 audio.addEventListener('ended', onEnded)
45 return () => { audio.removeEventListener('timeupdate', onTime); audio.removeEventListener('ended', onEnded) }
46 }, [])
47
48 const saveProgress = useCallback(async () => {
49 if (!track || !audioRef.current) return
50 const pos = audioRef.current.currentTime
51 const completed = pos >= track.duration_seconds * 0.95
52 await supabase.from('listen_history').upsert({ track_id: track.id, position_seconds: pos, completed, last_played_at: new Date().toISOString() }, { onConflict: 'user_id,track_id' })
53 }, [track])
54
55 useEffect(() => {
56 const interval = setInterval(saveProgress, 15000)
57 window.addEventListener('beforeunload', saveProgress)
58 return () => { clearInterval(interval); window.removeEventListener('beforeunload', saveProgress) }
59 }, [saveProgress])
60
61 const togglePlay = () => {
62 const audio = audioRef.current
63 if (!audio) return
64 if (playing) { audio.pause(); setPlaying(false) } else { audio.play(); setPlaying(true) }
65 }
66
67 const seek = (value: number[]) => {
68 const audio = audioRef.current
69 if (!audio) return
70 audio.currentTime = value[0]
71 setCurrentTime(value[0])
72 }
73
74 const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
75
76 return (
77 <div className="fixed bottom-0 left-0 right-0 bg-background border-t p-4">
78 <audio ref={audioRef} />
79 <div className="max-w-3xl mx-auto">
80 <div className="flex items-center gap-4 mb-3">
81 {track?.cover_art_url && <img src={track.cover_art_url} alt={track.title} className="w-10 h-10 rounded object-cover" />}
82 <div className="flex-1 min-w-0">
83 <p className="text-sm font-medium truncate">{track?.title ?? 'No track selected'}</p>
84 <p className="text-xs text-muted-foreground truncate">{track?.artist_name ?? ''}</p>
85 </div>
86 </div>
87 <div className="flex items-center gap-3">
88 <Button variant="ghost" size="icon" onClick={onPrev}><SkipBack className="h-4 w-4" /></Button>
89 <Button variant="ghost" size="icon" onClick={togglePlay}>
90 {playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
91 </Button>
92 <Button variant="ghost" size="icon" onClick={onNext}><SkipForward className="h-4 w-4" /></Button>
93 <span className="text-xs text-muted-foreground w-10 text-right">{fmt(currentTime)}</span>
94 <Slider className="flex-1" min={0} max={track?.duration_seconds ?? 100} step={1} value={[currentTime]} onValueChange={seek} />
95 <span className="text-xs text-muted-foreground w-10">{fmt(track?.duration_seconds ?? 0)}</span>
96 <Button variant="ghost" size="icon" onClick={() => { setMuted(!muted); if (audioRef.current) audioRef.current.muted = !muted }}>
97 {muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
98 </Button>
99 <Slider className="w-20" min={0} max={1} step={0.05} value={[volume]} onValueChange={([v]) => { setVolume(v); if (audioRef.current) audioRef.current.volume = v }} />
100 </div>
101 </div>
102 </div>
103 )
104}

Expected result: Clicking a track fetches the signed URL and starts playback. The seek bar advances in real time. Volume control works. When the signed URL is about to expire, it refreshes silently without interrupting playback.

4

Build the resumable upload for artists

Audio files can be hundreds of megabytes. A resumable upload using the tus protocol lets artists resume interrupted uploads. Supabase Storage supports tus natively.

prompt.txt
1Build an audio upload form at src/components/artist/TrackUploadForm.tsx.
2
3Install tus-js-client as a dependency. Ask Lovable to add it via package.json.
4
5Requirements:
6- Form fields: title (required), album Select (optional fetches this artist's albums), track_number (optional), genre Select, and the audio file Input.
7- On file selection, show: file name, file size (formatted), audio format.
8- Use tus-js-client for the upload:
9 import * as tus from 'tus-js-client'
10 const upload = new tus.Upload(file, {
11 endpoint: SUPABASE_URL + '/storage/v1/upload/resumable',
12 headers: { authorization: 'Bearer ' + (await supabase.auth.getSession()).data.session?.access_token, 'x-upsert': 'true' },
13 uploadDataDuringCreation: true,
14 removeFingerprintOnSuccess: true,
15 metadata: { bucketName: 'audio', objectName: storagePath, contentType: file.type },
16 chunkSize: 6 * 1024 * 1024, // 6MB chunks
17 onProgress: (uploaded, total) => setProgress(Math.round((uploaded / total) * 100)),
18 onSuccess: async () => { /* insert track row */ },
19 onError: (error) => setError(error.message),
20 })
21 upload.start()
22- Show a shadcn/ui Progress bar during upload.
23- On success, insert into tracks: { artist_id, album_id, title, duration_seconds (read from HTMLAudioElement), storage_path, file_size_bytes, mime_type, is_published: false }.
24 To get duration: const audio = new Audio(); audio.src = URL.createObjectURL(file); audio.onloadedmetadata = () => resolve(audio.duration).
25- After insert, show a success message with a 'Publish Track' Button that sets is_published = true.

Pro tip: Store the tus upload fingerprint key pattern in localStorage (tus-js-client does this automatically with removeFingerprintOnSuccess: true). If the artist refreshes the page mid-upload, call upload.findPreviousUploads() and upload.resumeFromPreviousUpload() to continue without re-selecting the file.

Expected result: Uploading a 50MB MP3 shows a progress bar. Interrupting and restarting the upload resumes from the last checkpoint. After success, a track row is created with is_published = false and the artist can publish it.

5

Build the playlist manager and queue system

Playlists let users curate track sequences. The player queue is managed in a Zustand store or React context so the current queue persists while navigating between pages.

prompt.txt
1Build playlist management and a play queue.
2
31. Playlists page at src/pages/Playlists.tsx:
4- Fetch user's playlists joined with track count from playlist_tracks.
5- Display as Cards with cover art, title, track count, and total duration (sum of tracks.duration_seconds).
6- 'New Playlist' Dialog: title, description, visibility toggle.
7- Playlist detail at /playlists/[id]: list of tracks in order with drag-to-reorder using position integer.
8 Each row: track number, album art, title, artist, duration, Remove button.
9 'Add Tracks' Sheet: search user's accessible tracks by title or artist, checkbox select, Add to Playlist.
10- 'Play Playlist' Button adds all tracks to the play queue and starts the first track.
11
122. Play queue (Zustand store or Context):
13- State: queue (Track[]), currentIndex (number), shuffle (bool), repeat ('off'|'track'|'queue').
14- Actions: setQueue(tracks), addToQueue(track), next(), prev(), toggleShuffle(), cycleRepeat().
15- When shuffle is true, next() picks a random unplayed track from the queue.
16- When repeat is 'track', onEnded loops the same track. When 'queue', wraps from last to first.
17- PlayerBar at the bottom reads from this store and calls next() when a track ends.

Expected result: Creating a playlist, adding tracks, and clicking Play All loads the track queue into the player. The player advances to the next track automatically. Shuffle and repeat modes work correctly.

Complete code

src/store/playerStore.ts
1import { create } from 'zustand'
2
3export type Track = {
4 id: string
5 title: string
6 artist_name: string
7 duration_seconds: number
8 cover_art_url?: string
9}
10
11type RepeatMode = 'off' | 'track' | 'queue'
12
13type PlayerState = {
14 queue: Track[]
15 currentIndex: number
16 shuffle: boolean
17 repeat: RepeatMode
18 playedIndices: Set<number>
19 setQueue: (tracks: Track[], startIndex?: number) => void
20 addToQueue: (track: Track) => void
21 removeFromQueue: (index: number) => void
22 next: () => void
23 prev: () => void
24 jumpTo: (index: number) => void
25 toggleShuffle: () => void
26 cycleRepeat: () => void
27 currentTrack: () => Track | null
28}
29
30export const usePlayerStore = create<PlayerState>((set, get) => ({
31 queue: [],
32 currentIndex: 0,
33 shuffle: false,
34 repeat: 'off',
35 playedIndices: new Set(),
36
37 setQueue: (tracks, startIndex = 0) =>
38 set({ queue: tracks, currentIndex: startIndex, playedIndices: new Set([startIndex]) }),
39
40 addToQueue: (track) =>
41 set((s) => ({ queue: [...s.queue, track] })),
42
43 removeFromQueue: (index) =>
44 set((s) => ({
45 queue: s.queue.filter((_, i) => i !== index),
46 currentIndex: index < s.currentIndex ? s.currentIndex - 1 : s.currentIndex,
47 })),
48
49 next: () =>
50 set((s) => {
51 if (s.repeat === 'track') return s
52 if (s.shuffle) {
53 const unplayed = s.queue.map((_, i) => i).filter((i) => !s.playedIndices.has(i))
54 if (unplayed.length === 0) {
55 if (s.repeat === 'queue') {
56 const next = Math.floor(Math.random() * s.queue.length)
57 return { currentIndex: next, playedIndices: new Set([next]) }
58 }
59 return s
60 }
61 const next = unplayed[Math.floor(Math.random() * unplayed.length)]
62 return { currentIndex: next, playedIndices: new Set([...s.playedIndices, next]) }
63 }
64 const next = s.currentIndex + 1
65 if (next >= s.queue.length) {
66 if (s.repeat === 'queue') return { currentIndex: 0 }
67 return s
68 }
69 return { currentIndex: next }
70 }),
71
72 prev: () =>
73 set((s) => ({ currentIndex: Math.max(0, s.currentIndex - 1) })),
74
75 jumpTo: (index) =>
76 set({ currentIndex: index }),
77
78 toggleShuffle: () =>
79 set((s) => ({ shuffle: !s.shuffle, playedIndices: new Set([s.currentIndex]) })),
80
81 cycleRepeat: () =>
82 set((s) => {
83 const next: RepeatMode = s.repeat === 'off' ? 'queue' : s.repeat === 'queue' ? 'track' : 'off'
84 return { repeat: next }
85 }),
86
87 currentTrack: () => {
88 const { queue, currentIndex } = get()
89 return queue[currentIndex] ?? null
90 },
91}))

Customization ideas

Subscription gating with Stripe

Add a subscriptions table (user_id, stripe_subscription_id, status, current_period_end). In the get-stream-url Edge Function, check if the track's artist requires a subscription and verify the user has an active subscription. Build a /subscribe page that creates a Stripe Checkout Session. A webhook handler updates subscription status on payment events.

Waveform visualization

After a track is uploaded, use the Web Audio API in a Supabase Edge Function or client-side to generate a waveform data array from the audio file. Store the waveform as a JSONB array of amplitude values (e.g., 200 samples) in the tracks table. The player renders this as an SVG or Canvas waveform behind the seek bar, making the seek interaction much more engaging.

Radio mode from listen history

Build a 'Radio' feature that uses the user's listen history to find similar tracks. Use a simple similarity algorithm: find tracks with matching genre and played by artists in the user's top 5 most-listened artists. Shuffle the results and play them in sequence. Add a Radio Button to the player controls that switches the queue source to this algorithm.

Artist follower system and release notifications

Add a follows table (follower_id, artist_id). Users follow artists and receive notifications when new tracks are published. Use a Supabase Database Webhook on the tracks table for INSERT + is_published = true events. The webhook fires an Edge Function that looks up all followers of the track's artist and inserts notification rows.

Lyrics sync display

Add a track_lyrics table (track_id, lines: jsonb — array of { time_seconds, text }). On the track detail page, show synchronized lyrics where the current line is highlighted as the track plays. The player component sends the current playback time to the lyrics component every 250ms and the component finds the matching line by comparing time_seconds.

Common pitfalls

Pitfall: Using a public Storage bucket for audio files

How to avoid: Use a private Supabase Storage bucket for all audio files. Route every playback request through the get-stream-url Edge Function which verifies auth before generating a signed URL. Never expose storage_path values to the client.

Pitfall: Fetching a new signed URL on every seek operation

How to avoid: One signed URL is valid for the entire track. Only refresh the URL when it is about to expire (handled by the timer in the AudioPlayer component). Seeking uses the existing audio.currentTime setter on the local HTMLAudioElement, which does not require a new URL.

Pitfall: Selecting the storage_path column in client-side Supabase queries

How to avoid: Never include storage_path in client-side SELECT queries. Use a PostgreSQL view or SECURITY DEFINER function that explicitly excludes this column. Only the Edge Function (which uses the service role key) should ever access storage_path.

Pitfall: Setting signed URL expiry shorter than the track duration

How to avoid: Set expiry to track.duration_seconds + 60 seconds to give the full track plus a buffer. For tracks over 5 minutes, the get-stream-url Edge Function should read duration_seconds from the track row and calculate the expiry dynamically.

Best practices

  • Store audio files with a path structure of {artist_id}/{track_id}/{slug}.{ext}. This makes it easy to delete all files for an artist or a specific track using the Storage prefix delete operation.
  • Never expose storage_path in any client-facing Supabase query. Create a database view tracks_public that excludes this column and grant the anon role SELECT on the view only.
  • Read track duration using the HTMLAudioElement.duration property before inserting the track row, not after. Create an Audio element in JavaScript, set the src to an object URL of the selected file, and read duration in the onloadedmetadata event handler.
  • Use zustand (or a similar lightweight state management library) for the player queue so the playing track persists when users navigate to different pages in the app.
  • Implement exponential backoff in the AudioPlayer for signed URL refresh failures. If the Edge Function is temporarily unavailable, retry after 1s, then 2s, then 4s before showing an error to the user.
  • Add a play event listener on the HTMLAudioElement that fires the first time a track plays. Use this to increment play_count once per listening session, not once per signed URL request (which would count refreshes as multiple plays).
  • For FLAC and high-bitrate audio files, set chunkSize in tus-js-client to 6MB. Smaller chunks increase the number of HTTP requests. Larger chunks risk timeout issues on slow connections.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a music streaming app in Lovable (React + Supabase). Audio is stored in a private Supabase Storage bucket. I have a Supabase Edge Function called get-stream-url that takes { track_id } and returns { signed_url, expires_in, duration_seconds }. Write a React AudioPlayer component using a useRef for the HTML5 Audio element. It should: fetch the signed URL when the track prop changes, set audio.src to the URL, implement a refresh timer that fires 30 seconds before expiry and calls the Edge Function again without interrupting playback, save listen progress every 15 seconds to Supabase listen_history, and show play/pause/seek/volume controls using shadcn/ui Slider and Button.

Lovable Prompt

Add a 'Recently Played' section to the music app homepage. Query the listen_history table for the current user, join with tracks and artist_profiles, filter where last_played_at > now() - interval '7 days', order by last_played_at desc, limit 8. Display as a horizontal scrollable row of Cards with track cover art (40x40), title, and artist name. Clicking a card calls setQueue([track], 0) from the player store to start playing immediately. Use Skeleton components as placeholder cards while loading.

Build Prompt

In Supabase, write a PostgreSQL function get_artist_stats(p_artist_id uuid) that returns JSON with: total_plays (sum of play_count from published tracks), total_tracks (count of published tracks), total_duration_seconds (sum of duration_seconds), top_tracks (array of top 5 tracks by play_count: [{id, title, play_count}]), listeners_this_week (count distinct user_id from listen_history joined through tracks where last_played_at > now() - interval '7 days'). Return as a single RPC call for the artist dashboard.

Frequently asked questions

How many concurrent listeners can Supabase Storage serve?

Supabase Storage is backed by S3-compatible object storage with CDN edge caching. There is no practical concurrent listener limit — the files are served from the CDN, not directly from your Supabase instance. Audio streaming requests bypass your Supabase connection pool entirely, so scaling to thousands of listeners is possible without hitting database connection limits.

Can listeners download the audio file instead of just streaming?

Only if you want them to. The signed URL generated by your Edge Function includes access for both streaming and downloading — it is just an HTTPS URL. To prevent downloads, there is no foolproof technical solution (audio streaming to a browser can always be captured). For content protection, rely on your terms of service and reasonable signed URL expiry rather than trying to prevent all technical extraction.

What audio formats should I support?

MP3 (audio/mpeg) has universal browser support and is the safest choice. AAC (audio/mp4) is slightly more efficient at similar bitrates. FLAC (audio/flac) works in modern browsers but not in Safari. WebM/Opus is excellent quality but has limited mobile support. Support MP3 as the primary format and treat FLAC as a high-quality optional format for users with compatible browsers.

How do I handle tracks that are too large for Supabase free tier storage?

The Supabase free tier includes 1GB of Storage. A typical 4-minute MP3 at 320kbps is about 10MB, so 1GB covers around 100 tracks. Supabase Pro adds 100GB of storage for $25/month. For a serious music platform, upgrade to Pro. Alternatively, keep audio in Supabase for 128kbps streaming copies and use a cheaper object storage (like Backblaze B2) for lossless originals.

How do I handle track deletion and clean up the Storage file?

Never delete a Storage file by just removing the database row. Add a Supabase Database Webhook on the tracks table for DELETE events that calls a cleanup Edge Function. The function reads the storage_path from the event payload (before delete) and calls supabase.storage.from('audio').remove([storagePath]) with the service role key to delete the actual file.

Can I stream lossless audio (FLAC) through this setup?

Yes, but with caveats. FLAC files are large (a 4-minute FLAC can be 30–80MB). Streaming starts after enough data is buffered, so seekability depends on the browser's ability to request byte ranges from the signed URL. Supabase Storage supports HTTP Range requests, which enables seeking in FLAC streams. Test in Chrome and Firefox — Safari has inconsistent FLAC support.

Is there help available for production music platform builds?

RapidDev builds production-grade Lovable apps including music platforms with subscription gating, Stripe billing, waveform visualization, and artist analytics. Reach out if your music streaming backend needs expert architecture support.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.