# How to Build a Music Streaming Backend with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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.

```
Create a music streaming backend with Supabase. Set up these tables:

- artist_profiles: id (references auth.users), display_name, bio, avatar_url, genre_tags (text[]), created_at
- albums: id, artist_id (references artist_profiles), title, cover_art_url, release_year (int), genre (text), created_at
- 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
- playlists: id, owner_id (references auth.users), title, description, cover_art_url, is_public (bool default true), created_at
- playlist_tracks: id, playlist_id (references playlists), track_id (references tracks), position (int), added_at
- 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)

RLS:
- artist_profiles: publicly readable, users manage their own
- albums: publicly readable
- 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
- playlists: publicly readable if is_public = true, owners can CRUD their own
- playlist_tracks: inherits playlist visibility
- listen_history: users can only read/write their own rows

Create a Supabase Storage bucket called 'audio':
- PRIVATE bucket (no public URL access)
- Allowed MIME types: audio/mpeg, audio/flac, audio/mp4, audio/ogg, audio/wav
- 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.

```
Create a Supabase Edge Function at supabase/functions/get-stream-url/index.ts.

Receives: { track_id: string } in body.
Requires authentication (validate JWT).

Logic:
1. Extract user_id from JWT.
2. Fetch track from Supabase using service role key:
   SELECT id, storage_path, is_published, duration_seconds FROM tracks WHERE id = track_id AND is_published = true
   If no row returned, return 404.
3. Optional access check (uncomment for subscription model):
   Check if user has an active subscription in a subscriptions table. If not and the track requires subscription, return 403.
4. Generate a signed URL with expiry of 300 seconds (5 minutes):
   const { data, error } = await supabaseAdmin.storage.from('audio').createSignedUrl(track.storage_path, 300)
5. If error generating signed URL, return 500.
6. 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().
7. Increment play_count: UPDATE tracks SET play_count = play_count + 1 WHERE id = track_id.
8. Return: { signed_url: data.signedUrl, expires_in: 300, track_id, duration_seconds: track.duration_seconds }

Store 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.

```
import { useEffect, useRef, useState, useCallback } from 'react'
import { supabase } from '@/integrations/supabase/client'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'

type Track = { id: string; title: string; artist_name: string; duration_seconds: number; cover_art_url?: string }

export function AudioPlayer({ track, onNext, onPrev }: { track: Track | null; onNext: () => void; onPrev: () => void }) {
  const audioRef = useRef<HTMLAudioElement>(null)
  const [playing, setPlaying] = useState(false)
  const [currentTime, setCurrentTime] = useState(0)
  const [volume, setVolume] = useState(0.8)
  const [muted, setMuted] = useState(false)
  const urlRefreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null)

  const fetchAndLoad = useCallback(async (trackId: string, resumeAt?: number) => {
    const { data, error } = await supabase.functions.invoke('get-stream-url', { body: { track_id: trackId } })
    if (error || !data?.signed_url) return
    const audio = audioRef.current
    if (!audio) return
    audio.src = data.signed_url
    audio.currentTime = resumeAt ?? 0
    audio.load()
    if (playing) audio.play()
    if (urlRefreshTimer.current) clearTimeout(urlRefreshTimer.current)
    const refreshIn = Math.max((data.expires_in - 30) * 1000, 5000)
    urlRefreshTimer.current = setTimeout(() => fetchAndLoad(trackId, audio.currentTime), refreshIn)
  }, [playing])

  useEffect(() => {
    if (!track) return
    supabase.from('listen_history').select('position_seconds').eq('track_id', track.id).single()
      .then(({ data }) => fetchAndLoad(track.id, data?.position_seconds ?? 0))
    return () => { if (urlRefreshTimer.current) clearTimeout(urlRefreshTimer.current) }
  }, [track?.id])

  useEffect(() => {
    const audio = audioRef.current
    if (!audio) return
    const onTime = () => setCurrentTime(audio.currentTime)
    const onEnded = () => { setPlaying(false); onNext() }
    audio.addEventListener('timeupdate', onTime)
    audio.addEventListener('ended', onEnded)
    return () => { audio.removeEventListener('timeupdate', onTime); audio.removeEventListener('ended', onEnded) }
  }, [])

  const saveProgress = useCallback(async () => {
    if (!track || !audioRef.current) return
    const pos = audioRef.current.currentTime
    const completed = pos >= track.duration_seconds * 0.95
    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' })
  }, [track])

  useEffect(() => {
    const interval = setInterval(saveProgress, 15000)
    window.addEventListener('beforeunload', saveProgress)
    return () => { clearInterval(interval); window.removeEventListener('beforeunload', saveProgress) }
  }, [saveProgress])

  const togglePlay = () => {
    const audio = audioRef.current
    if (!audio) return
    if (playing) { audio.pause(); setPlaying(false) } else { audio.play(); setPlaying(true) }
  }

  const seek = (value: number[]) => {
    const audio = audioRef.current
    if (!audio) return
    audio.currentTime = value[0]
    setCurrentTime(value[0])
  }

  const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`

  return (
    <div className="fixed bottom-0 left-0 right-0 bg-background border-t p-4">
      <audio ref={audioRef} />
      <div className="max-w-3xl mx-auto">
        <div className="flex items-center gap-4 mb-3">
          {track?.cover_art_url && <img src={track.cover_art_url} alt={track.title} className="w-10 h-10 rounded object-cover" />}
          <div className="flex-1 min-w-0">
            <p className="text-sm font-medium truncate">{track?.title ?? 'No track selected'}</p>
            <p className="text-xs text-muted-foreground truncate">{track?.artist_name ?? ''}</p>
          </div>
        </div>
        <div className="flex items-center gap-3">
          <Button variant="ghost" size="icon" onClick={onPrev}><SkipBack className="h-4 w-4" /></Button>
          <Button variant="ghost" size="icon" onClick={togglePlay}>
            {playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
          </Button>
          <Button variant="ghost" size="icon" onClick={onNext}><SkipForward className="h-4 w-4" /></Button>
          <span className="text-xs text-muted-foreground w-10 text-right">{fmt(currentTime)}</span>
          <Slider className="flex-1" min={0} max={track?.duration_seconds ?? 100} step={1} value={[currentTime]} onValueChange={seek} />
          <span className="text-xs text-muted-foreground w-10">{fmt(track?.duration_seconds ?? 0)}</span>
          <Button variant="ghost" size="icon" onClick={() => { setMuted(!muted); if (audioRef.current) audioRef.current.muted = !muted }}>
            {muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
          </Button>
          <Slider className="w-20" min={0} max={1} step={0.05} value={[volume]} onValueChange={([v]) => { setVolume(v); if (audioRef.current) audioRef.current.volume = v }} />
        </div>
      </div>
    </div>
  )
}
```

**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.

```
Build an audio upload form at src/components/artist/TrackUploadForm.tsx.

Install tus-js-client as a dependency. Ask Lovable to add it via package.json.

Requirements:
- Form fields: title (required), album Select (optional — fetches this artist's albums), track_number (optional), genre Select, and the audio file Input.
- On file selection, show: file name, file size (formatted), audio format.
- Use tus-js-client for the upload:
  import * as tus from 'tus-js-client'
  const upload = new tus.Upload(file, {
    endpoint: SUPABASE_URL + '/storage/v1/upload/resumable',
    headers: { authorization: 'Bearer ' + (await supabase.auth.getSession()).data.session?.access_token, 'x-upsert': 'true' },
    uploadDataDuringCreation: true,
    removeFingerprintOnSuccess: true,
    metadata: { bucketName: 'audio', objectName: storagePath, contentType: file.type },
    chunkSize: 6 * 1024 * 1024, // 6MB chunks
    onProgress: (uploaded, total) => setProgress(Math.round((uploaded / total) * 100)),
    onSuccess: async () => { /* insert track row */ },
    onError: (error) => setError(error.message),
  })
  upload.start()
- Show a shadcn/ui Progress bar during upload.
- 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 }.
  To get duration: const audio = new Audio(); audio.src = URL.createObjectURL(file); audio.onloadedmetadata = () => resolve(audio.duration).
- 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.

```
Build playlist management and a play queue.

1. Playlists page at src/pages/Playlists.tsx:
- Fetch user's playlists joined with track count from playlist_tracks.
- Display as Cards with cover art, title, track count, and total duration (sum of tracks.duration_seconds).
- 'New Playlist' Dialog: title, description, visibility toggle.
- Playlist detail at /playlists/[id]: list of tracks in order with drag-to-reorder using position integer.
  Each row: track number, album art, title, artist, duration, Remove button.
  'Add Tracks' Sheet: search user's accessible tracks by title or artist, checkbox select, Add to Playlist.
- 'Play Playlist' Button adds all tracks to the play queue and starts the first track.

2. Play queue (Zustand store or Context):
- State: queue (Track[]), currentIndex (number), shuffle (bool), repeat ('off'|'track'|'queue').
- Actions: setQueue(tracks), addToQueue(track), next(), prev(), toggleShuffle(), cycleRepeat().
- When shuffle is true, next() picks a random unplayed track from the queue.
- When repeat is 'track', onEnded loops the same track. When 'queue', wraps from last to first.
- 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 example

File: `src/store/playerStore.ts`

```typescript
import { create } from 'zustand'

export type Track = {
  id: string
  title: string
  artist_name: string
  duration_seconds: number
  cover_art_url?: string
}

type RepeatMode = 'off' | 'track' | 'queue'

type PlayerState = {
  queue: Track[]
  currentIndex: number
  shuffle: boolean
  repeat: RepeatMode
  playedIndices: Set<number>
  setQueue: (tracks: Track[], startIndex?: number) => void
  addToQueue: (track: Track) => void
  removeFromQueue: (index: number) => void
  next: () => void
  prev: () => void
  jumpTo: (index: number) => void
  toggleShuffle: () => void
  cycleRepeat: () => void
  currentTrack: () => Track | null
}

export const usePlayerStore = create<PlayerState>((set, get) => ({
  queue: [],
  currentIndex: 0,
  shuffle: false,
  repeat: 'off',
  playedIndices: new Set(),

  setQueue: (tracks, startIndex = 0) =>
    set({ queue: tracks, currentIndex: startIndex, playedIndices: new Set([startIndex]) }),

  addToQueue: (track) =>
    set((s) => ({ queue: [...s.queue, track] })),

  removeFromQueue: (index) =>
    set((s) => ({
      queue: s.queue.filter((_, i) => i !== index),
      currentIndex: index < s.currentIndex ? s.currentIndex - 1 : s.currentIndex,
    })),

  next: () =>
    set((s) => {
      if (s.repeat === 'track') return s
      if (s.shuffle) {
        const unplayed = s.queue.map((_, i) => i).filter((i) => !s.playedIndices.has(i))
        if (unplayed.length === 0) {
          if (s.repeat === 'queue') {
            const next = Math.floor(Math.random() * s.queue.length)
            return { currentIndex: next, playedIndices: new Set([next]) }
          }
          return s
        }
        const next = unplayed[Math.floor(Math.random() * unplayed.length)]
        return { currentIndex: next, playedIndices: new Set([...s.playedIndices, next]) }
      }
      const next = s.currentIndex + 1
      if (next >= s.queue.length) {
        if (s.repeat === 'queue') return { currentIndex: 0 }
        return s
      }
      return { currentIndex: next }
    }),

  prev: () =>
    set((s) => ({ currentIndex: Math.max(0, s.currentIndex - 1) })),

  jumpTo: (index) =>
    set({ currentIndex: index }),

  toggleShuffle: () =>
    set((s) => ({ shuffle: !s.shuffle, playedIndices: new Set([s.currentIndex]) })),

  cycleRepeat: () =>
    set((s) => {
      const next: RepeatMode = s.repeat === 'off' ? 'queue' : s.repeat === 'queue' ? 'track' : 'off'
      return { repeat: next }
    }),

  currentTrack: () => {
    const { queue, currentIndex } = get()
    return queue[currentIndex] ?? null
  },
}))
```

## Common mistakes

- **Using a public Storage bucket for audio files** — Public bucket URLs are permanent and discoverable. Any user with the URL can download the audio file without authentication, bypassing any subscription or access control you have built. Fix: 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.
- **Fetching a new signed URL on every seek operation** — Calling the Edge Function every time the user seeks to a different position adds latency to every seek and creates unnecessary Edge Function invocations. Fix: 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.
- **Selecting the storage_path column in client-side Supabase queries** — If storage_path values are returned to the browser, they can be used to call supabase.storage.from('audio').download(path) directly, bypassing the signed URL gating entirely. Fix: 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.
- **Setting signed URL expiry shorter than the track duration** — If you set a 60-second expiry on a 5-minute track, the signed URL expires before the track finishes playing, causing the audio to stop with a network error. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/music-streaming-backend
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/music-streaming-backend
