# How to Build Music streaming backend with V0

- Tool: v0
- Difficulty: Advanced
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a music catalog and streaming backend with V0 using Next.js, Supabase Storage, and shadcn/ui. Features audio file management, streaming endpoints with range request support, playlist CRUD, play count analytics, and signed URLs for private audio files. Takes about 2-4 hours.

## Before you start

- A V0 account (Premium or higher — this is a complex build)
- A Supabase project with Storage enabled (Pro recommended for storage limits)
- Audio files to upload (MP3 or AAC format, under 100MB each)
- Your music catalog structure (artists, albums, tracks)

## Step-by-step guide

### 1. Set up the database schema and private audio bucket

Create the Supabase schema for artists, albums, tracks, playlists, and play history. Configure a private Storage bucket for audio files.

```
// Paste this prompt into V0's AI chat:
// Build a music streaming backend. Create a Supabase schema:
// 1. artists: id (uuid PK), user_id (uuid FK to auth.users), name (text), bio (text), image_url (text)
// 2. albums: id (uuid PK), artist_id (uuid FK to artists), title (text), cover_url (text), release_date (date)
// 3. tracks: id (uuid PK), album_id (uuid FK to albums), artist_id (uuid FK to artists), title (text), duration_seconds (int), file_url (text), file_size_bytes (bigint), genre (text), play_count (int DEFAULT 0)
// 4. playlists: id (uuid PK), user_id (uuid FK to auth.users), name (text), description (text), is_public (boolean DEFAULT true), created_at (timestamptz)
// 5. playlist_tracks: playlist_id (uuid FK to playlists), track_id (uuid FK to tracks), position (int), added_at (timestamptz), PRIMARY KEY (playlist_id, track_id)
// 6. play_history: id (uuid PK), user_id (uuid FK to auth.users), track_id (uuid FK to tracks), played_at (timestamptz), duration_listened (int)
// Add RLS policies. Also create a private Storage bucket called 'audio' with 100MB file size limit.
```

> Pro tip: Use a private bucket for audio files. This means files cannot be accessed directly — you control access through signed URLs generated server-side.

**Expected result:** All tables are created with RLS policies. A private 'audio' Storage bucket is configured with 100MB file limit.

### 2. Build the audio streaming API with signed URLs

Create the API route that generates signed URLs for audio streaming. The signed URL has a 1-hour expiry and is served with CDN caching headers for performance.

```
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ trackId: string }> }
) {
  const { trackId } = await params

  const { data: track } = await supabase
    .from('tracks')
    .select('file_url, title')
    .eq('id', trackId)
    .single()

  if (!track?.file_url) {
    return NextResponse.json({ error: 'Track not found' }, { status: 404 })
  }

  const { data: signedUrl } = await supabase.storage
    .from('audio')
    .createSignedUrl(track.file_url, 3600)

  if (!signedUrl) {
    return NextResponse.json({ error: 'Failed to generate URL' }, { status: 500 })
  }

  return NextResponse.redirect(signedUrl.signedUrl, {
    headers: {
      'Cache-Control': 'public, max-age=3600',
      'Accept-Ranges': 'bytes',
    },
  })
}
```

**Expected result:** The streaming endpoint generates a signed URL and redirects the audio player to it. URLs expire after 1 hour.

### 3. Build the music catalog and playlist UI

Create the catalog browsing pages and playlist management interface with an inline audio player component.

```
// Paste this prompt into V0's AI chat:
// Create music streaming pages:
// 1. app/page.tsx — featured playlists Card grid + trending tracks Table (title, artist, album, duration, play_count, play Button)
// 2. app/library/page.tsx — user's playlists grid + recently played history Table
// 3. app/playlist/[id]/page.tsx — tracklist Table with position, title, artist, duration. Add inline play Button per track. Show playlist cover, name, description, track count.
// 4. Add a persistent audio player bar at the bottom: track title + artist, Slider for scrubber, volume Slider, play/pause/skip Buttons, Sheet for now-playing queue
// Use shadcn/ui Card for albums, Table for tracklists, Slider for audio controls, DropdownMenu for track options (add to playlist, share), Sheet for queue.
```

> Pro tip: Store SUPABASE_SERVICE_ROLE_KEY in V0's Vars tab without NEXT_PUBLIC_ prefix — it is needed server-side to generate signed Storage URLs for private audio files.

**Expected result:** The catalog shows featured playlists and trending tracks. Playlists display tracklists with play buttons. A persistent player bar controls audio.

### 4. Implement play count tracking and analytics

Create the API route that increments play counts and logs listening history when a track is played. This data powers trending tracks and user history features.

```
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { track_id, user_id, duration_listened } = await req.json()

  if (!track_id) {
    return NextResponse.json({ error: 'Missing track_id' }, { status: 400 })
  }

  await supabase.rpc('increment_play_count', { p_track_id: track_id })

  if (user_id) {
    await supabase.from('play_history').insert({
      user_id,
      track_id,
      played_at: new Date().toISOString(),
      duration_listened: duration_listened || 0,
    })
  }

  return NextResponse.json({ success: true })
}
```

**Expected result:** Play counts increment atomically. User listening history is logged for recently played and analytics features.

### 5. Add playlist CRUD and deploy

Build playlist creation, track adding/removing, and reordering. Then deploy the streaming backend.

```
// Paste this prompt into V0's AI chat:
// Add playlist management:
// 1. Server Action createPlaylist: insert playlist with name, description, is_public
// 2. Server Action addTrackToPlaylist: insert into playlist_tracks with max position + 1
// 3. Server Action removeTrackFromPlaylist: delete from playlist_tracks and reorder positions
// 4. Server Action reorderTrack: update position values for drag-and-drop reordering
// 5. 'New Playlist' Dialog on library page: name Input, description Textarea, public Switch
// 6. On track DropdownMenu, add 'Add to Playlist' option that opens a Dialog listing user's playlists with add Buttons
// 7. On playlist page, add DropdownMenu per track: Remove from playlist, Move up, Move down
// Also create the Supabase RPC function: increment_play_count that does UPDATE tracks SET play_count = play_count + 1 WHERE id = p_track_id
```

**Expected result:** Playlists support creation, track adding/removing, and reordering. Play counts increment reliably. The app is deployed to Vercel.

## Complete code example

File: `app/api/stream/[trackId]/route.ts`

```typescript
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ trackId: string }> }
) {
  const { trackId } = await params

  const { data: track } = await supabase
    .from('tracks')
    .select('file_url, title, duration_seconds')
    .eq('id', trackId)
    .single()

  if (!track?.file_url) {
    return NextResponse.json({ error: 'Track not found' }, { status: 404 })
  }

  const { data } = await supabase.storage
    .from('audio')
    .createSignedUrl(track.file_url, 3600)

  if (!data?.signedUrl) {
    return NextResponse.json(
      { error: 'URL generation failed' },
      { status: 500 }
    )
  }

  return NextResponse.json({
    stream_url: data.signedUrl,
    title: track.title,
    duration: track.duration_seconds,
  })
}
```

## Common mistakes

- **Using a public Storage bucket for audio files** — Public buckets allow anyone with the URL to download files permanently. Users could share direct links and bypass your platform entirely. Fix: Use a private bucket and generate signed URLs with 1-hour expiry through the streaming API. This forces all access through your application.
- **Exposing SUPABASE_SERVICE_ROLE_KEY with NEXT_PUBLIC_ prefix** — The service role key is needed to generate signed URLs for private files. Exposing it means anyone can generate URLs for any file in your bucket. Fix: Store it in V0's Vars tab without any prefix. Only use it in API routes that generate signed URLs.
- **Incrementing play_count with a regular UPDATE instead of atomic operation** — Multiple concurrent plays can read the same count and both write the same value, losing one increment. Fix: Use a Supabase RPC function: UPDATE tracks SET play_count = play_count + 1 WHERE id = p_track_id. The database handles concurrency.

## Best practices

- Use a private Supabase Storage bucket for audio files and generate signed URLs server-side with 1-hour expiry
- Store SUPABASE_SERVICE_ROLE_KEY in V0's Vars tab without NEXT_PUBLIC_ prefix for server-only signed URL generation
- Use Supabase RPC for atomic play count increments to handle concurrent plays correctly
- Add CDN caching headers (Cache-Control: public, max-age=3600) to streaming responses for performance
- Set a 100MB file size limit on the audio bucket to prevent abuse
- Use V0's Design Mode (Option+D) to adjust the player bar height, album card sizes, and tracklist spacing without spending credits
- Implement the audio player as a persistent client component in the layout, not per-page, to prevent interrupting playback on navigation

## Frequently asked questions

### Why use a private bucket instead of public for audio files?

A public bucket allows anyone to download files directly with the URL. With a private bucket, all access goes through your API which generates time-limited signed URLs. This prevents hotlinking and lets you track plays accurately.

### Does the audio player support seeking (skipping to a specific time)?

Yes. The signed URL points to the actual audio file which supports HTTP range requests natively. The HTML5 Audio element handles seeking automatically when users drag the Slider scrubber.

### Do I need a paid Supabase plan?

The free tier (1GB storage) works for small catalogs. For production with many audio files, the Pro plan ($25/month, 100GB storage) is recommended. Audio files are typically 3-10MB each.

### Do I need a paid V0 plan?

Yes, Premium ($20/month) at minimum. The streaming backend has multiple complex pages (catalog, playlists, player, analytics) and API routes that require many prompts.

### How do I upload audio files?

Upload through the Supabase Dashboard Storage interface for initial setup. For production, build an artist upload page that uses the Supabase Storage SDK to upload files to the private 'audio' bucket and stores metadata in the tracks table.

### How do I deploy the streaming backend?

Click Share in V0, then Publish to Production. Set SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY in the Vars tab. Create the private 'audio' bucket in Supabase Dashboard before deploying.

### Can RapidDev help build a custom music streaming platform?

Yes. RapidDev has built over 600 apps including media streaming platforms with CDN integration, analytics dashboards, and recommendation engines. Book a free consultation to discuss your platform requirements.

---

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