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.
What you're building
Independent music platforms and podcast hosts need a backend for managing audio catalogs, serving streams, and tracking listener analytics. Building this on top of Supabase Storage gives you private file hosting with signed URL access control.
V0 generates the catalog UI, playlist management, and player components from prompts. Supabase Storage handles audio file hosting in a private bucket, and the streaming API route generates signed URLs with CDN caching headers for performance.
The architecture uses a private Supabase Storage bucket for audio files, an API route that generates signed URLs for streaming, Server Components for the catalog and playlists, and a client component audio player with shadcn/ui Slider for scrubbing and volume control.
Final result
A music streaming backend with catalog management, playlist CRUD, audio streaming via signed URLs, play tracking, and an inline audio player.
Tech stack
Prerequisites
- 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)
Build steps
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.
1// Paste this prompt into V0's AI chat:2// Build a music streaming backend. Create a Supabase schema:3// 1. artists: id (uuid PK), user_id (uuid FK to auth.users), name (text), bio (text), image_url (text)4// 2. albums: id (uuid PK), artist_id (uuid FK to artists), title (text), cover_url (text), release_date (date)5// 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)6// 4. playlists: id (uuid PK), user_id (uuid FK to auth.users), name (text), description (text), is_public (boolean DEFAULT true), created_at (timestamptz)7// 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)8// 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)9// 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.
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.
1import { createClient } from '@supabase/supabase-js'2import { NextRequest, NextResponse } from 'next/server'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function GET(10 req: NextRequest,11 { params }: { params: Promise<{ trackId: string }> }12) {13 const { trackId } = await params1415 const { data: track } = await supabase16 .from('tracks')17 .select('file_url, title')18 .eq('id', trackId)19 .single()2021 if (!track?.file_url) {22 return NextResponse.json({ error: 'Track not found' }, { status: 404 })23 }2425 const { data: signedUrl } = await supabase.storage26 .from('audio')27 .createSignedUrl(track.file_url, 3600)2829 if (!signedUrl) {30 return NextResponse.json({ error: 'Failed to generate URL' }, { status: 500 })31 }3233 return NextResponse.redirect(signedUrl.signedUrl, {34 headers: {35 'Cache-Control': 'public, max-age=3600',36 'Accept-Ranges': 'bytes',37 },38 })39}Expected result: The streaming endpoint generates a signed URL and redirects the audio player to it. URLs expire after 1 hour.
Build the music catalog and playlist UI
Create the catalog browsing pages and playlist management interface with an inline audio player component.
1// Paste this prompt into V0's AI chat:2// Create music streaming pages:3// 1. app/page.tsx — featured playlists Card grid + trending tracks Table (title, artist, album, duration, play_count, play Button)4// 2. app/library/page.tsx — user's playlists grid + recently played history Table5// 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.6// 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 queue7// 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.
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.
1import { createClient } from '@supabase/supabase-js'2import { NextRequest, NextResponse } from 'next/server'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function POST(req: NextRequest) {10 const { track_id, user_id, duration_listened } = await req.json()1112 if (!track_id) {13 return NextResponse.json({ error: 'Missing track_id' }, { status: 400 })14 }1516 await supabase.rpc('increment_play_count', { p_track_id: track_id })1718 if (user_id) {19 await supabase.from('play_history').insert({20 user_id,21 track_id,22 played_at: new Date().toISOString(),23 duration_listened: duration_listened || 0,24 })25 }2627 return NextResponse.json({ success: true })28}Expected result: Play counts increment atomically. User listening history is logged for recently played and analytics features.
Add playlist CRUD and deploy
Build playlist creation, track adding/removing, and reordering. Then deploy the streaming backend.
1// Paste this prompt into V0's AI chat:2// Add playlist management:3// 1. Server Action createPlaylist: insert playlist with name, description, is_public4// 2. Server Action addTrackToPlaylist: insert into playlist_tracks with max position + 15// 3. Server Action removeTrackFromPlaylist: delete from playlist_tracks and reorder positions6// 4. Server Action reorderTrack: update position values for drag-and-drop reordering7// 5. 'New Playlist' Dialog on library page: name Input, description Textarea, public Switch8// 6. On track DropdownMenu, add 'Add to Playlist' option that opens a Dialog listing user's playlists with add Buttons9// 7. On playlist page, add DropdownMenu per track: Remove from playlist, Move up, Move down10// Also create the Supabase RPC function: increment_play_count that does UPDATE tracks SET play_count = play_count + 1 WHERE id = p_track_idExpected result: Playlists support creation, track adding/removing, and reordering. Play counts increment reliably. The app is deployed to Vercel.
Complete code
1import { createClient } from '@supabase/supabase-js'2import { NextRequest, NextResponse } from 'next/server'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function GET(10 req: NextRequest,11 { params }: { params: Promise<{ trackId: string }> }12) {13 const { trackId } = await params1415 const { data: track } = await supabase16 .from('tracks')17 .select('file_url, title, duration_seconds')18 .eq('id', trackId)19 .single()2021 if (!track?.file_url) {22 return NextResponse.json({ error: 'Track not found' }, { status: 404 })23 }2425 const { data } = await supabase.storage26 .from('audio')27 .createSignedUrl(track.file_url, 3600)2829 if (!data?.signedUrl) {30 return NextResponse.json(31 { error: 'URL generation failed' },32 { status: 500 }33 )34 }3536 return NextResponse.json({37 stream_url: data.signedUrl,38 title: track.title,39 duration: track.duration_seconds,40 })41}Customization ideas
Personalized recommendations
Build a simple recommendation engine that suggests tracks based on listening history genre preferences using a collaborative filtering query.
Waveform visualization
Add an audio waveform display using the wavesurfer.js library, showing the audio shape and current playback position.
Artist analytics dashboard
Build a dashboard for artists showing play counts over time, top listeners, geographic distribution, and revenue from streams.
Offline playback caching
Implement a service worker that caches recently played tracks for offline listening using the Cache API.
Common pitfalls
Pitfall: Using a public Storage bucket for audio files
How to avoid: Use a private bucket and generate signed URLs with 1-hour expiry through the streaming API. This forces all access through your application.
Pitfall: Exposing SUPABASE_SERVICE_ROLE_KEY with NEXT_PUBLIC_ prefix
How to avoid: Store it in V0's Vars tab without any prefix. Only use it in API routes that generate signed URLs.
Pitfall: Incrementing play_count with a regular UPDATE instead of atomic operation
How to avoid: 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
AI prompts to try
Copy these prompts to build this project faster.
I'm building a music streaming backend with Next.js App Router and Supabase Storage. I need an API route at app/api/stream/[trackId]/route.ts that looks up the track's file_url in the database, generates a signed URL from the private 'audio' Storage bucket with 1-hour expiry, and returns it. The audio player will use this URL for playback. Please also write the PostgreSQL function increment_play_count that atomically increments the play_count column.
Create a persistent audio player component that sits in the app layout and survives page navigation. It should accept a track queue, display current track title and artist, have a Slider for scrubbing (using currentTime and duration), a volume Slider, play/pause/skip Buttons, and a Sheet showing the upcoming queue. Use the HTML5 Audio element wrapped in a React ref. Fetch the signed stream URL from /api/stream/[trackId] when a new track starts.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation