TL;DR
The one-paragraph version before you dive in.
Paste the starter prompt below into Lovable Build mode and get a music streaming backend with a sticky player bar, artist upload flow to Cloudflare R2, server-side tier-gated signed URL minting, and Stripe premium subscriptions. The key architectural decision — R2 for audio, Supabase Storage only for cover art — is baked in from prompt one. Full build takes a few days and around 200-350 credits.
Setup checklist
Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.
Cloud tab settings
Database
Stores tracks, playlists, playlist_tracks, plays, and profiles with subscription_status. The starter prompt creates all tables and indexes via migration.
- 1Click the + button next to Preview to open the Cloud tab
- 2Click Database — the starter prompt will create all tables via migration
- 3Leave it empty for now
Auth
Email+password for artists and listeners. Artist role stored in app_metadata so the upload route gate works server-side.
- 1Cloud tab → Users & Auth
- 2Enable Email sign-in (default — already on)
- 3To make a user an artist: click their row → Edit → set app_metadata to {"role": "artist"}
- 4Premium subscription status lives in profiles.subscription_status (updated by Stripe webhook), not in app_metadata
Storage
Public bucket for cover art and waveform thumbnails ONLY. Audio files go to Cloudflare R2 — never serve audio from Supabase Storage.
- 1Cloud tab → Storage
- 2Click Create bucket, name it cover-art, toggle Public ON
- 3Do not create a bucket for audio — audio goes to R2 (see Secrets below)
Edge Functions
Three Edge Functions handle signed URL minting with tier check, artist upload to R2, and play recording.
- 1Cloud tab → Edge Functions — leave empty; the follow-up prompts generate each function
- 2After generating mint-signed-url, add R2 credentials to Cloud tab → Secrets (see Secrets section)
- 3After generating stripe-webhook, register the webhook endpoint in Stripe Dashboard pointing at your deployed URL
Secrets (Cloud tab → Secrets)
AWS_ACCESS_KEY_IDPurpose: Allows mint-signed-url and upload-track Edge Functions to generate presigned R2 URLs using the S3-compatible API.
Where to get it: https://dash.cloudflare.com → R2 → Manage R2 API Tokens → Create API Token with Object Read & Write on your bucket. The Access Key ID appears once — copy it immediately.
AWS_SECRET_ACCESS_KEYPurpose: Signs R2 presigned URL requests alongside the Access Key ID.
Where to get it: Same Cloudflare R2 API Token creation flow — the Secret Access Key appears alongside the Access Key ID. Copy both at the same time.
R2_ENDPOINTPurpose: The S3-compatible endpoint used by mint-signed-url and upload-track to generate presigned URLs.
Where to get it: In Cloudflare R2 dashboard → your bucket → Settings → S3 API — copy the endpoint URL (format: https://<account-id>.r2.cloudflarestorage.com).
R2_BUCKETPurpose: Scopes all R2 operations to the correct bucket.
Where to get it: The name of your R2 bucket as shown in the Cloudflare dashboard (e.g. music-catalog).
STRIPE_SECRET_KEYPurpose: Creates Stripe checkout sessions and billing portal sessions for the premium subscription tier.
Where to get it: https://dashboard.stripe.com/apikeys — use the API Key icon in Lovable chat input or paste directly in Cloud tab → Secrets. Never paste in chat messages.
STRIPE_WEBHOOK_SECRETPurpose: Verifies Stripe webhook signatures in the stripe-webhook Edge Function.
Where to get it: https://dashboard.stripe.com/webhooks — after creating the webhook endpoint pointing at your deployed app, copy the signing secret (whsec_...).
Preflight checklist
- You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded)
- You've created a Cloudflare account and set up an R2 bucket before running the mint-signed-url follow-up — R2 has a generous free tier (10GB storage, zero egress)
- You're on Pro $25/mo plan and budget 1-2 top-ups — R2 setup and Stripe wiring each consume 80-100 credits
- Credit estimates are extrapolated from Storage-heavy upload + membership-site cousin patterns — no documented exact-match Lovable music streaming build exists; treat all numbers as ranges
The starter prompt — paste this first
Copy this. Paste it into Lovable Agent Mode (the default chat at the bottom-left of the editor). Hit send.
Build a music streaming backend and player UI. Stack assumptions: React + Vite + TypeScript + Tailwind + shadcn/ui already scaffolded by Lovable, React Router for routes, Supabase JS client for data access against Lovable Cloud. Audio playback via the native HTML5 audio element wrapped in a useAudioPlayer custom hook.
## Database schema (create one migration)
Create five tables in the public schema:
1. `tracks` — id (uuid pk default gen_random_uuid()), artist_id (uuid not null references auth.users(id)), title (text not null), album (text), cover_art_path (text — Supabase Storage path for cover art image), audio_storage_provider (text not null default 'r2' check (audio_storage_provider in ('r2','s3','supabase'))), audio_key (text not null — the R2/S3 object key, NOT a full URL), duration_s (int), tier (text not null default 'free' check (tier in ('free','premium'))), play_count (int not null default 0), created_at (timestamptz default now()).
2. `playlists` — id (uuid pk default gen_random_uuid()), owner_id (uuid not null references auth.users(id)), name (text not null), is_public (bool not null default false), created_at (timestamptz default now()).
3. `playlist_tracks` — playlist_id (uuid not null references playlists(id) on delete cascade), track_id (uuid not null references tracks(id) on delete cascade), position (int not null), added_at (timestamptz default now()), PRIMARY KEY (playlist_id, track_id).
4. `plays` — id (bigserial pk), track_id (uuid not null references tracks(id)), user_id (uuid references auth.users(id)), played_at (timestamptz default now()), duration_played_s (int), completed (bool not null default false).
5. `profiles` — id (uuid pk references auth.users(id) on delete cascade), display_name (text), avatar_url (text), stripe_customer_id (text), subscription_status (text not null default 'free' check (subscription_status in ('free','active','past_due','canceled'))).
## RLS policies
Enable RLS on all five tables.
- tracks: SELECT for anyone where tier = 'free'; SELECT for authenticated where tier = 'premium' using a SECURITY DEFINER function can_play_track(p_track_id uuid) that checks profiles.subscription_status = 'active' for the caller. Artist INSERT/UPDATE/DELETE own rows where artist_id = auth.uid().
- playlists: owner-only write; SELECT for owner OR is_public = true.
- playlist_tracks: inherit playlist visibility via a SECURITY DEFINER function can_see_playlist(p_playlist_id uuid). INSERT/DELETE for playlist owner only.
- plays: INSERT for authenticated only; SELECT for admin via has_role('admin').
- profiles: per-user SELECT/UPDATE for own row; display_name and avatar_url SELECT for anyone.
Create SECURITY DEFINER plpgsql functions:
- `has_role(p_role text)` returns boolean: RETURN (auth.jwt() ->> 'app_metadata')::jsonb ->> 'role' = p_role;
- `can_play_track(p_track_id uuid)` returns boolean: checks tracks.tier = 'free' OR (tracks.tier = 'premium' AND EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND subscription_status = 'active')).
Also create a trigger `on_play_insert` that fires AFTER INSERT ON plays to UPDATE tracks SET play_count = play_count + 1.
## Layout
Create `src/layouts/AppLayout.tsx` — a three-region layout:
1. Left sidebar (240px fixed): logo, nav links (Home, Browse, Library, Playlists, Upload [artist-only, hidden if not artist role], Account). Nav links use React Router NavLink with active styles.
2. Main scroll area: router outlet.
3. Sticky bottom PlayerBar (80px): cover art thumbnail (40x40), track title + artist name, play/pause button (shadcn Button), scrubber (shadcn Slider, 0-100% progress), current time / total duration, volume control. PlayerBar is driven by a useAudioPlayer hook that manages the audio element.
## Pages and routes
Create these routes in src/App.tsx:
- / (Home.tsx) — hero banner + 'Featured Tracks' grid (8 free tracks) + 'Premium Catalog' teaser row with lock icons for non-subscribers.
- /browse (Browse.tsx) — search input (debounced 300ms) + genre filter chips + track list. Each row = TrackRow component.
- /track/:id (TrackDetail.tsx) — large cover art, title, artist name, TierBadge, play button, duration, add-to-playlist button (Sheet with playlist selector), play count.
- /artist/:id (Artist.tsx) — artist display_name, avatar, list of their tracks.
- /playlist/:id (Playlist.tsx) — playlist name, owner, track list with play-all button. Draggable reorder deferred to follow-up.
- /library (Library.tsx) — auth-gated; the logged-in user's playlists + recently played tracks.
- /upload (Upload.tsx) — artist-only (AuthGuard checks has_role('artist')); UploadDropzone component that accepts audio files + a form for title/album/tier/cover art.
- /account (Account.tsx) — current subscription status, Stripe portal link (disabled for now, added in follow-up), sign out.
- /login (Login.tsx) — email+password.
## Key components
Create: TrackRow (compact list row with cover art, title, artist, duration, TierBadge, play button that sets the track in useAudioPlayer, add-to-playlist icon), TierBadge (shows 'Free' in gray or 'Premium' in amber with a lock icon for locked tracks), CoverArt (lazy-loaded img with skeleton placeholder), UploadDropzone (file input + drag-drop area, shows selected filename and size, calls upload-track Edge Function placeholder — returns mock URL for now), PlaylistCard (thumbnail grid of 4 cover arts + playlist name + track count).
## useAudioPlayer hook
Create `src/hooks/useAudioPlayer.ts` — manages a single HTMLAudioElement. Exposes: currentTrack (track object or null), isPlaying, currentTime, duration, volume, play(track), pause(), seekTo(seconds), setVolume(0-1). On play(track): fetches a signed URL by calling the mint-signed-url Edge Function (stub it to return a placeholder URL for now, real implementation in follow-up #1). On ended: record the play via record-play Edge Function (also stub for now).
## Styling
Dark mode as default (className='dark' on html element, dark: variants throughout). Primary accent color purple-600. PlayerBar has a border-t border-white/10 and a backdrop-blur-sm background. Cover art always square (aspect-square). Use lucide-react for play/pause/skip/volume icons. shadcn Slider for the scrubber — style the thumb with accent color.
Generate the migration first, then AppLayout + useAudioPlayer, then pages in order, then components.What this prompt generates
- Creates one SQL migration with 5 tables, RLS policies, can_play_track SECURITY DEFINER function, and play_count trigger
- Scaffolds AppLayout with left sidebar navigation, main scroll area, and sticky PlayerBar driven by useAudioPlayer hook
- Generates all 9 pages wired to React Router with auth-gating on /upload, /library, /account
- Builds TrackRow, TierBadge, CoverArt, UploadDropzone, PlaylistCard components
- Creates useAudioPlayer hook with stubbed signed-URL fetching and play recording (wired in follow-ups)
Paste into: Lovable Agent Mode (the default chat at the bottom-left of the editor)
Expected output
What Lovable will generate after the starter prompt runs successfully.
Files
supabase/migrations/0001_streaming_schema.sql5 tables + RLS + has_role() + can_play_track() + play_count trigger
src/layouts/AppLayout.tsxSidebar nav + main scroll area + sticky PlayerBar
src/hooks/useAudioPlayer.tsSingle audio element manager with play/pause/seek/volume
src/components/PlayerBar.tsxSticky bottom player with scrubber and volume control
src/components/TrackRow.tsxCompact list item with play, tier badge, add-to-playlist
src/components/TierBadge.tsxFree/Premium badge with lock icon for non-subscribers
src/components/CoverArt.tsxLazy-loaded square cover art with skeleton
src/components/UploadDropzone.tsxDrag-drop audio file input, calls upload-track Edge Function
src/components/PlaylistCard.tsx4-thumbnail mosaic + playlist name + track count
src/pages/Home.tsxFeatured tracks grid + premium catalog teaser
src/pages/Browse.tsxSearch + genre filter + track list
src/pages/TrackDetail.tsxLarge cover, play button, add-to-playlist sheet
src/pages/Artist.tsxArtist profile + their catalog
src/pages/Playlist.tsxPlaylist track list with play-all
src/pages/Library.tsxUser playlists + recently played
src/pages/Upload.tsxArtist-only upload form gated by has_role('artist')
src/pages/Account.tsxSubscription status + Stripe portal link
supabase/functions/mint-signed-url/index.tsChecks tier + subscription_status, returns R2 presigned URL
supabase/functions/upload-track/index.tsAccepts multipart audio, uploads to R2, writes tracks row
supabase/functions/record-play/index.tsLogs to plays table; trigger bumps play_count
Routes
Home with featured tracks and premium teaser
Search and genre-filtered track list
Track detail with play button and playlist add
Artist profile and catalog
Playlist with track list and play-all
Auth-gated user playlists and history
Artist-only upload form
Subscription management
DB Tables
tracks| Column | Type |
|---|---|
| id | uuid primary key |
| artist_id | uuid references auth.users(id) |
| audio_key | text not null |
| tier | text check in (free,premium) |
| play_count | int default 0 |
RLS: Public SELECT for free tier; authenticated SELECT for premium via can_play_track() SECURITY DEFINER
playlists| Column | Type |
|---|---|
| id | uuid primary key |
| owner_id | uuid references auth.users(id) |
| is_public | bool default false |
RLS: Owner write; SELECT for owner OR is_public=true
playlist_tracks| Column | Type |
|---|---|
| playlist_id | uuid references playlists(id) on delete cascade |
| track_id | uuid references tracks(id) on delete cascade |
| position | int not null |
RLS: Visibility inherited from playlist via can_see_playlist() SECURITY DEFINER
plays| Column | Type |
|---|---|
| id | bigserial primary key |
| track_id | uuid references tracks(id) |
| user_id | uuid references auth.users(id) |
| completed | bool default false |
RLS: INSERT-only for authenticated; SELECT for admin via has_role('admin')
profiles| Column | Type |
|---|---|
| id | uuid primary key references auth.users(id) |
| stripe_customer_id | text |
| subscription_status | text default free |
RLS: Per-user read/write own; display_name public read
Components
PlayerBarSticky bottom player with scrubber, volume, cover art thumbnail
TrackRowCompact list item with play button, tier badge, playlist add
TierBadgeFree/Premium label with lock icon
CoverArtLazy-loaded square image with skeleton
UploadDropzoneDrag-drop audio upload to R2 via Edge Function
PlaylistCard4-thumbnail mosaic card for playlist grid
Follow-up prompts
Paste these into Agent Mode one by one, in order, after the starter prompt finishes.
Set up Cloudflare R2 with signed-URL minting
Real R2 signed URL minting with tier check — audio now loads from Cloudflare CDN at zero egress cost
Implement the mint-signed-url Edge Function at supabase/functions/mint-signed-url/index.ts. This function is called by useAudioPlayer on every play.
The function should:
1. Read the Authorization header and get the caller's user ID via supabase.auth.getUser().
2. Query the tracks table for the requested track_id to get tier and audio_key.
3. If tracks.tier = 'premium': check profiles.subscription_status = 'active' for the caller. If not active, return 403 with {error: 'Premium subscription required'}.
4. Generate a presigned GET URL for the R2 object using the AWS S3 v4 signing library. Use: endpoint = Deno.env.get('R2_ENDPOINT'), region = 'auto', bucket = Deno.env.get('R2_BUCKET'), key = track.audio_key, expiresIn = 3600 (1 hour TTL — long enough for a 1-hour listening session without re-minting mid-track).
5. Return {signed_url, expires_at}.
Use the aws4 signing package via esm.sh: import { SignatureV4 } from 'https://esm.sh/@aws-sdk/signature-v4@3'; import { Sha256 } from 'https://esm.sh/@aws-crypto/sha256-js@3'. Or use @aws-sdk/s3-request-presigner with @aws-sdk/client-s3 (also available via esm.sh).
In useAudioPlayer.ts, replace the stub URL fetch with a real call to this Edge Function. On play(track), call the function, set audio.src = signed_url, then call audio.play().
Also ensure all R2 credentials are in Cloud tab → Secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET.When to use: Before inviting any real users — this is the single most important architectural piece in the entire build
Wire artist upload to R2 via upload-track Edge Function
Real audio upload flow: artist drops a file, it goes to R2, metadata lands in the DB, and the track is immediately playable
Implement the upload-track Edge Function at supabase/functions/upload-track/index.ts.
The function should accept a multipart/form-data request with fields: audio_file (binary), title (string), album (string, optional), tier ('free' or 'premium'), cover_art_file (binary, optional).
The function should:
1. Verify the caller has role 'artist' (read from JWT app_metadata).
2. Generate a unique audio_key: `tracks/${auth.uid()}/${Date.now()}_${filename}`.
3. Upload the audio file to R2 via S3 PutObject using the same AWS credentials from Secrets.
4. If cover_art_file is present, upload it to Supabase Storage bucket 'cover-art' and get the storage path.
5. INSERT into tracks (artist_id, title, album, audio_key, audio_storage_provider='r2', tier, cover_art_path) and return the new track_id.
In UploadDropzone.tsx, replace the stub call with a real fetch to this Edge Function. Show upload progress (use XMLHttpRequest or the Fetch API's ReadableStream to track bytes sent). After upload completes, show a success toast with the track title and navigate to /track/:new_id.When to use: Once mint-signed-url works and you can verify a signed URL plays in the browser
Add Stripe premium subscription
Full Stripe subscription with checkout, webhook reconciliation, and customer portal for self-serve billing
Add a Stripe premium subscription tier. Use STRIPE_SECRET_KEY from Cloud Secrets (add it via the API Key icon in chat, or directly in Cloud tab → Secrets — never paste it in a chat message).
Create two Edge Functions:
1. `create-checkout-session` — accepts {price_id} in the body. Reads the caller's profiles.stripe_customer_id (call ensure-stripe-customer if null). Creates a Stripe Checkout Session with mode='subscription', the price_id, success_url='/account?session_id={CHECKOUT_SESSION_ID}', cancel_url='/account'. Returns {checkout_url}. React redirects to checkout_url.
2. `stripe-webhook` — reads raw body with `const rawBody = await req.text()` (NOT req.json()), gets the stripe-signature header, calls `await stripe.webhooks.constructEventAsync(rawBody, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET'))`. On customer.subscription.updated: update profiles.subscription_status from the subscription status field. On customer.subscription.deleted: set profiles.subscription_status = 'canceled'. Return 200 immediately (Stripe retries on >5s response).
In /account, add a PricingSection with a single 'Go Premium' card showing the monthly price and feature list. The CTA button calls create-checkout-session. Show current subscription_status with a badge. Add a 'Manage subscription' button that calls a customer-portal Edge Function returning a Stripe portal URL.
Create a Stripe Product and Price in your Stripe Dashboard first (e.g. $9/mo for Premium catalog access). Note the price_id (price_xxx) and set it as a constant in the create-checkout-session function or in Cloud Secrets as STRIPE_PRICE_ID.When to use: Once audio playback works for free tracks and you have at least 10 tracks uploaded
Add play recording and admin insights page
Play recording with admin analytics dashboard showing top tracks, listener counts, and tier split
Implement the record-play Edge Function at supabase/functions/record-play/index.ts. The function accepts {track_id, duration_played_s, completed: boolean} in the body. It inserts a row into the plays table. The on_play_insert trigger bumps tracks.play_count automatically.
In useAudioPlayer.ts, call record-play in two places:
1. When the audio 'ended' event fires (completed=true, duration_played_s=duration).
2. When the user skips after playing more than 30 seconds (completed=false, duration_played_s=currentTime).
Create an /admin/insights page (gated by has_role('admin')). Show:
- Top 10 tracks by play_count this week (query plays joined to tracks, group by track_id, last 7 days).
- Unique listeners today vs yesterday (KPI card).
- Free vs premium play split pie chart (Recharts PieChart).
- A line chart of daily play_count totals for the last 30 days (Recharts LineChart).
Add 'Insights' to the admin sidebar section in AppLayout.When to use: Once playback works and you have 50+ plays to visualize
Add playlist drag-reorder and add-to-playlist drawer
Drag-to-reorder track order in playlists and an add-to-playlist drawer on every track row
Add two playlist UX features:
1. Drag-to-reorder on /playlist/:id — install dnd-kit: import { DndContext, closestCenter } from '@dnd-kit/core' and { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'. Wrap the track list in DndContext + SortableContext. On drag end, update all affected playlist_tracks.position values in a single Supabase upsert.
2. Add-to-playlist Sheet on every TrackRow — clicking the + icon opens a shadcn Sheet from the right. The Sheet lists the user's playlists (query playlists where owner_id = auth.uid()). Clicking a playlist inserts into playlist_tracks with position = (SELECT COALESCE(MAX(position), 0) + 1 FROM playlist_tracks WHERE playlist_id = ?). Show a success toast: 'Added to [playlist name]'. If the track is already in the playlist (UNIQUE constraint violation), show 'Already in this playlist'.When to use: Once core playback and uploads work; a quality-of-life feature, not a launch blocker
Add DMCA takedown moderation queue
DMCA takedown request form and admin moderation queue — legally required for platforms with user-generated content
If you allow third-party artist uploads, you legally need a DMCA takedown workflow. Add:
1. A `takedown_requests` table: id (uuid pk), track_id (uuid references tracks(id)), reporter_email (text not null), reason (text not null), status (text default 'pending' check in ('pending','upheld','dismissed')), created_at (timestamptz default now()). RLS: INSERT for anyone (public — reporters don't need to be logged in); SELECT/UPDATE for admin only.
2. A 'Report track' button on /track/:id that opens a Dialog with a reason text area and submits to takedown_requests. No login required.
3. An /admin/takedowns page (admin-only) listing pending requests with uphold/dismiss buttons. On uphold: set tracks.tier = 'free' (or add a is_removed bool column and filter it out of all queries). Send a Resend email to the artist's email notifying them of the takedown.
This is not optional if you allow uploads from third parties — implement it before your first public-facing launch.When to use: Before your first public launch if you allow third-party artist uploads
Common errors
Real error strings you'll see. Find yours, paste the fix prompt.
Failed to load resource: net::ERR_BLOCKED_BY_RESPONSE.NotSameOrigin on audio elementYou're serving audio from Supabase Storage with a public URL and the CORS headers aren't configured for media scrubbing, especially on Safari where range requests require proper CORS headers.
Do not serve audio from Supabase Storage. Move to R2 with signed URLs — Cloudflare CDN sets the correct CORS headers for media playback automatically. If you need to test before R2 is set up, use the HTML5 audio element without crossOrigin='use-credentials' attribute and a public Supabase Storage URL. For production, complete follow-up #1 (mint-signed-url) first.
SignatureDoesNotMatch from R2 on play requestEither AWS_SECRET_ACCESS_KEY in Cloud Secrets has trailing whitespace or the wrong value, you're using the wrong region (R2 requires 'auto'), or the URL is being double-encoded.
Re-open Cloud tab → Secrets → AWS_SECRET_ACCESS_KEY and check for any trailing newline or space — delete and re-paste the value. For R2, confirm the region parameter is exactly 'auto' in your SignatureV4 or presigner configuration. Log the exact presigned URL from mint-signed-url and test it directly in curl before touching the React client.
Premium track plays for a free user (403 expected but not returned)Tier check is in a React useEffect or local state check instead of inside mint-signed-url on the server. The user opened DevTools, called the Edge Function directly with their auth token, and received a signed URL.
Move all tier enforcement into mint-signed-url. The Edge Function must: (1) read the caller's JWT, (2) look up their profiles.subscription_status, (3) read the track tier, (4) return 403 if track.tier='premium' and subscription_status is not 'active'. The React TierBadge and locked UI are for UX only — never for security. Never return a signed URL for a premium track to a non-subscriber, regardless of what the client claims.
Audio plays for 5 seconds then stops; browser console shows 403Your signed URL TTL is 5 minutes and the user paused the track for longer than that, or the URL expired while the player was buffering a long track.
Increase signed URL TTL from 5 minutes to 1 hour in mint-signed-url (expiresIn = 3600). Alternatively, in useAudioPlayer, listen to the audio 'error' event — if the error is a 403 or 401, call mint-signed-url again to get a fresh URL and set audio.src = new_url, then resume from audio.currentTime.
Storage bandwidth exceeded / unexpected Lovable Cloud charge after a track gets sharedYou served audio directly from Supabase Storage instead of R2. One viral share on social media consumed tens of thousands of plays in a day, blowing through the 5GB free egress and triggering overage billing.
Immediately make the Supabase Storage audio bucket private to stop the bleeding. Then complete follow-up #1 (mint-signed-url + R2 setup). Update the audio_storage_provider column to 'r2' and the audio_key to the R2 object path for every affected track. R2 has zero egress fees — this problem will not recur once you're on R2.
Cannot read properties of null (reading 'play') in PlayerBarPlayerBar's audio ref isn't ready when useEffect runs, or the currentTrack state was set before the audio element mounted.
In useAudioPlayer.ts, wrap all audio.play(), audio.pause(), and audio.currentTime assignments in if (audioRef.current) guards. In the play() function, use a useEffect that depends on [currentTrack] and only calls audio.load() + audio.play() when both audioRef.current and currentTrack are non-null.
Cost reality
What this build actually costs — no surprises on your card.
Recommended Lovable plan
Pro $25/mo plus 1-2 top-ups ($15 per 50 credits). R2 setup and Stripe subscription wiring each consume 70-110 credits. Treat this as a 2-3 weekend build, not a single afternoon.
Monthly run cost breakdown
~200-350 credits (starter ~80-120, six follow-ups ~200-250 if done in full, plus 2-3 rounds of debugging the signed-URL flow and webhook). These are extrapolated estimates — no documented exact-match Lovable music streaming build exists; treat all numbers as ranges. total| Item | Cost |
|---|---|
| Lovable Pro Can drop to Free after launch if not iterating | $25/mo |
| Cloudflare R2 10GB free storage, truly zero egress — this is why you use R2 | $0.015/GB-month storage, $0 egress |
| Lovable Cloud / Supabase Free DB + Auth + Storage for cover art only — stays within free tier for most catalogs | $0/mo |
| Stripe Plus Billing add-on 0.5-0.8% if using Stripe Billing features | 2.9% + 30¢ per subscription payment |
| Resend Free up to 3,000 emails/month | $0/mo |
| Custom domain | $10-15/yr |
Scaling notes: With R2 for audio, bandwidth is not your scaling constraint — Stripe per-subscription fees scale linearly with revenue. The only infrastructure bottleneck is Supabase Free's 500MB DB (enough for ~1M track metadata rows) and 50,000 MAU auth limit. Upgrade to Supabase Pro ($25/mo) when either is hit. Without R2, Supabase Free's 5GB egress = roughly 1,667 plays of a 3MB track — one viral track ends your free tier immediately.
Production checklist
Steps to take before you share the URL with real users.
Audio Security
Verify tier gating is enforced in mint-signed-url, not React
Sign in as a free user. Open DevTools → Network. Click a premium track play button — the request to mint-signed-url should return 403. If it returns a URL, tier enforcement is in the wrong place — move it server-side.
Confirm audio bucket (if using Supabase Storage for anything) is private
Cloud tab → Storage → your bucket → Settings — confirm Public Access is OFF. Cover art can stay public; any audio in Storage must be private.
DMCA Compliance
Add a DMCA agent designation before accepting third-party uploads
Register a DMCA agent with the US Copyright Office at copyright.gov/dmca-directory (currently $6/3-year term) and publish your DMCA contact address in a /legal page. This is required for DMCA safe harbor protection for platforms with user-generated content.
Stripe Go-Live
Switch to live Stripe keys and create a separate live webhook endpoint
Update STRIPE_SECRET_KEY in Cloud Secrets to sk_live_... In Stripe Dashboard → Webhooks, add a new endpoint pointing at your production URL (separate from the test endpoint). Copy the new signing secret to STRIPE_WEBHOOK_SECRET. Test with a real $1 charge on your own card.
Domain & Backups
Custom domain and SSL
Click the Publish icon (top right in Lovable) → Settings → Custom Domain. Follow the CNAME DNS instructions. SSL is provisioned automatically.
Verify R2 bucket has object versioning or backup strategy
In Cloudflare R2 dashboard → your bucket → Settings → enable Object Versioning if available, or set up a periodic R2 sync to a backup bucket. Audio files are not recoverable if accidentally deleted without versioning.
Frequently asked questions
Why can't I just use Supabase Storage for the audio files?
You can for development and for the first handful of users. The problem is egress cost at scale. Supabase Free gives you 5GB of bandwidth per month, which is roughly 1,667 plays of a 3MB track. One track shared on social media can exceed that in an afternoon. Supabase Pro gives 250GB — enough for about 83,000 plays, which is reasonable for a small catalog but still vulnerable to a single viral moment. Cloudflare R2, by contrast, has zero egress fees. You pay $0.015/GB for storage and nothing for bandwidth. That math doesn't change at any scale. The follow-up #1 prompt sets up R2 from scratch — run it before you share the app with anyone outside your testing circle.
Do I need a music license to operate this?
It depends on what you're streaming. If you're streaming music you own or have licensed directly from artists (your own label, a white-label catalog, meditation sounds you commissioned), you generally do not need a separate performance license — you already have the rights. If you plan to stream commercially released recordings from major or indie labels, you need mechanical and performance licenses from the copyright holders or their representatives (ASCAP, BMI, SESAC in the US, similar bodies internationally). This is not legal advice — consult an entertainment lawyer before streaming third-party catalog music to the public.
Can I host video in this same pattern?
Yes, with one significant addition: you should use HLS (HTTP Live Streaming) for video instead of a direct file download, because video files are much larger and users expect adaptive bitrate (quality drops on slow connections rather than buffering). R2 can serve HLS segments fine, but you need a transcoding step (FFmpeg or a service like Mux) to convert uploaded MP4s to HLS manifests. That transcoding pipeline is not covered in this kit — it's a meaningful engineering project on its own. For short video clips under 5 minutes where adaptive bitrate is not critical, the same signed-URL pattern works.
How do I handle offline playback and native mobile apps?
This kit builds a web app — offline playback and native mobile (iOS/Android) are out of scope for Lovable. Offline requires a service worker with a cache strategy for signed URLs (tricky because signed URLs expire) or a native app with local file download. Native mobile apps require React Native or Flutter, which Lovable does not generate. If offline or native mobile are requirements, this is the point where you'd engage a development team — the $13K-$25K RapidDev engagement covers that scope.
Can I use AWS S3 instead of Cloudflare R2?
Yes — the same S3-compatible API works for both. The mint-signed-url and upload-track functions use the AWS SDK which speaks to S3 natively. The difference is cost: S3 charges $0.09/GB for egress (same as Supabase Storage Pro tier), while R2 charges $0. For a streaming app, that egress difference compounds quickly. If you already have S3 infrastructure and prefer AWS, replace the R2 endpoint with your S3 bucket endpoint and set the region correctly (R2 uses 'auto', S3 uses your actual region like 'us-east-1'). Everything else in the prompt kit works unchanged.
What about HLS adaptive bitrate streaming?
This kit streams audio as a direct file download via a presigned URL, which works for MP3/AAC files up to ~50MB without adaptive bitrate. For large files or video, HLS is better: it splits the file into small segments, generates a playlist (.m3u8), and the player selects quality based on network speed. Implementing HLS requires a transcoding pipeline (FFmpeg on an EC2/VM, or a managed service like Mux at $0.0055/min) at upload time, then serving .m3u8 and .ts segment files from R2. That transcoding step is not in this kit but uses the same R2 signed-URL pattern for serving segments.
Can RapidDev build a production streaming platform with apps?
If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.
Need a production-grade version?
RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.
Book a free consultation30-min call. No commitment.