Skip to main content
RapidDev - Software Development Agency
Lovable PromptsB2C SaaSAdvanced

Build a Music Streaming Backend in Lovable

A music streaming backend with artist uploads to Cloudflare R2, server-side signed URL minting with tier checking, playlists, play tracking, and Stripe premium subscriptions — bandwidth-sustainable from day one.

Time to MVP

a few days

Credits

~200-350 credits for full chain

Difficulty

Advanced

Cloud features

4

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.

  1. 1Click the + button next to Preview to open the Cloud tab
  2. 2Click Database — the starter prompt will create all tables via migration
  3. 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.

  1. 1Cloud tab → Users & Auth
  2. 2Enable Email sign-in (default — already on)
  3. 3To make a user an artist: click their row → Edit → set app_metadata to {"role": "artist"}
  4. 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.

  1. 1Cloud tab → Storage
  2. 2Click Create bucket, name it cover-art, toggle Public ON
  3. 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.

  1. 1Cloud tab → Edge Functions — leave empty; the follow-up prompts generate each function
  2. 2After generating mint-signed-url, add R2 credentials to Cloud tab → Secrets (see Secrets section)
  3. 3After generating stripe-webhook, register the webhook endpoint in Stripe Dashboard pointing at your deployed URL

Secrets (Cloud tab → Secrets)

AWS_ACCESS_KEY_ID

Purpose: 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_KEY

Purpose: 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_ENDPOINT

Purpose: 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_BUCKET

Purpose: 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_KEY

Purpose: 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_SECRET

Purpose: 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.

lovable-agent-mode.txt
~80-120 credits
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.sql

5 tables + RLS + has_role() + can_play_track() + play_count trigger

src/layouts/AppLayout.tsx

Sidebar nav + main scroll area + sticky PlayerBar

src/hooks/useAudioPlayer.ts

Single audio element manager with play/pause/seek/volume

src/components/PlayerBar.tsx

Sticky bottom player with scrubber and volume control

src/components/TrackRow.tsx

Compact list item with play, tier badge, add-to-playlist

src/components/TierBadge.tsx

Free/Premium badge with lock icon for non-subscribers

src/components/CoverArt.tsx

Lazy-loaded square cover art with skeleton

src/components/UploadDropzone.tsx

Drag-drop audio file input, calls upload-track Edge Function

src/components/PlaylistCard.tsx

4-thumbnail mosaic + playlist name + track count

src/pages/Home.tsx

Featured tracks grid + premium catalog teaser

src/pages/Browse.tsx

Search + genre filter + track list

src/pages/TrackDetail.tsx

Large cover, play button, add-to-playlist sheet

src/pages/Artist.tsx

Artist profile + their catalog

src/pages/Playlist.tsx

Playlist track list with play-all

src/pages/Library.tsx

User playlists + recently played

src/pages/Upload.tsx

Artist-only upload form gated by has_role('artist')

src/pages/Account.tsx

Subscription status + Stripe portal link

supabase/functions/mint-signed-url/index.ts

Checks tier + subscription_status, returns R2 presigned URL

supabase/functions/upload-track/index.ts

Accepts multipart audio, uploads to R2, writes tracks row

supabase/functions/record-play/index.ts

Logs to plays table; trigger bumps play_count

Routes
/

Home with featured tracks and premium teaser

/browse

Search and genre-filtered track list

/track/:id

Track detail with play button and playlist add

/artist/:id

Artist profile and catalog

/playlist/:id

Playlist with track list and play-all

/library

Auth-gated user playlists and history

/upload

Artist-only upload form

/account

Subscription management

DB Tables
tracks
ColumnType
iduuid primary key
artist_iduuid references auth.users(id)
audio_keytext not null
tiertext check in (free,premium)
play_countint default 0

RLS: Public SELECT for free tier; authenticated SELECT for premium via can_play_track() SECURITY DEFINER

playlists
ColumnType
iduuid primary key
owner_iduuid references auth.users(id)
is_publicbool default false

RLS: Owner write; SELECT for owner OR is_public=true

playlist_tracks
ColumnType
playlist_iduuid references playlists(id) on delete cascade
track_iduuid references tracks(id) on delete cascade
positionint not null

RLS: Visibility inherited from playlist via can_see_playlist() SECURITY DEFINER

plays
ColumnType
idbigserial primary key
track_iduuid references tracks(id)
user_iduuid references auth.users(id)
completedbool default false

RLS: INSERT-only for authenticated; SELECT for admin via has_role('admin')

profiles
ColumnType
iduuid primary key references auth.users(id)
stripe_customer_idtext
subscription_statustext default free

RLS: Per-user read/write own; display_name public read

Components
PlayerBar

Sticky bottom player with scrubber, volume, cover art thumbnail

TrackRow

Compact list item with play button, tier badge, playlist add

TierBadge

Free/Premium label with lock icon

CoverArt

Lazy-loaded square image with skeleton

UploadDropzone

Drag-drop audio upload to R2 via Edge Function

PlaylistCard

4-thumbnail mosaic card for playlist grid

Follow-up prompts

Paste these into Agent Mode one by one, in order, after the starter prompt finishes.

1

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

~70-90 credits
prompt
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

2

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

~50-70 credits
prompt
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

3

Add Stripe premium subscription

Full Stripe subscription with checkout, webhook reconciliation, and customer portal for self-serve billing

~90-110 credits
prompt
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

4

Add play recording and admin insights page

Play recording with admin analytics dashboard showing top tracks, listener counts, and tier split

~45-55 credits
prompt
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

5

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

~35-45 credits
prompt
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

6

Add DMCA takedown moderation queue

DMCA takedown request form and admin moderation queue — legally required for platforms with user-generated content

~35-45 credits
prompt
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 element

You'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.

Fix — paste into Lovable Agent Mode
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 request

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

Fix — paste into Lovable Agent Mode
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.

Fix — paste into Lovable Agent Mode
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 403

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

Fix — paste into Lovable Agent Mode
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 shared

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

Fix — paste into Lovable Agent Mode
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 PlayerBar

PlayerBar's audio ref isn't ready when useEffect runs, or the currentTrack state was set before the audio element mounted.

Fix — paste into Lovable Agent Mode
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
ItemCost
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 consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

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.