Skip to main content
RapidDev - Software Development Agency
App Featuresmedia-content20 min read

How to Add a Background Music Player to Your App — Copy-Paste Prompts Inside

A background music player needs a single <audio> element mounted at the app root (so it survives route changes), a Zustand store holding playback state, and a fixed mini-player UI accessible from every page. With Lovable or V0 you can ship a working persistent player in 2–4 hours for $0/month — all processing is in-browser. The autoplay policy and SSR localStorage crash are the two issues that break every first build.

4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members

Feature spec

Beginner

Category

media-content

Build with AI

2–4 hours with Lovable or V0

Custom build

3–5 days custom dev

Running cost

$0–$25/mo (Supabase free tier covers audio hosting for most apps)

Works on

Web

Everything it takes to ship a Background Music Player — parts, prompts, and real costs.

TL;DR

A background music player needs a single <audio> element mounted at the app root (so it survives route changes), a Zustand store holding playback state, and a fixed mini-player UI accessible from every page. With Lovable or V0 you can ship a working persistent player in 2–4 hours for $0/month — all processing is in-browser. The autoplay policy and SSR localStorage crash are the two issues that break every first build.

What a Background Music Player Actually Is

A background music player is ambient audio that continues playing as the user navigates between pages — think a mood-setting playlist for a portfolio site, a meditation app, or a creative tool. The key constraint is persistence: a standard React component unmounts on navigation, stopping the audio. The fix is mounting the <audio> element once in the root layout component so it never unmounts, then using a Zustand store to share playback state across the entire component tree. The mini-player UI floats fixed at the bottom of the screen and connects to the same store. Browsers block autoplay until the user interacts with the page — handling this gracefully (not silently) is the feature's most user-visible product decision.

What users consider table stakes in 2026

  • Audio continues playing uninterrupted when navigating between pages — no restart, no gap
  • Mini-player bar accessible from every page with play/pause, previous, next, progress slider, volume, and mute
  • Track metadata displayed: title, artist, and cover art thumbnail
  • Volume and muted state persists between browser sessions (user preference remembered)
  • Autoplay policy compliant — audio starts only after user clicks, with a clear hint to the user before that
  • Graceful handling of failed track loads without crashing the player or losing the queue

Anatomy of the Feature

Six components. Lovable gets four of them right on the first prompt. The autoplay gate and the SSR localStorage crash are the two pieces that require explicit instruction.

Layers:UIData

Global audio context

UI

A single HTML5 <audio> element mounted in the root layout component (app/layout.tsx in Next.js App Router, App.tsx root in React). This element never unmounts regardless of route changes. It receives its src, volume, and play/pause commands from the Zustand store. A useEffect in the layout syncs the audio element's state to the store on every relevant state change.

Note: The <audio> element must have the autoplay, muted, and playsInline attributes removed — autoplay will be blocked; start playback programmatically via audio.play() inside a click handler only.

Player state store

Data

Zustand store (^5.x) holding: tracks (array), currentIndex, isPlaying, volume (0–1 float), isMuted, currentTime, duration. Actions: play(), pause(), next(), previous(), seek(seconds), setVolume(v), toggleMute(). The store is the single source of truth — the <audio> element is a controlled side effect that reacts to store changes.

Note: Persist volume and isMuted to localStorage via Zustand persist middleware. Wrap the storage option with typeof window !== 'undefined' checks to prevent ReferenceError on SSR. Other state (currentIndex, isPlaying) should NOT be persisted — resuming the exact track and position from a previous session is typically surprising behavior.

Mini player UI

UI

Fixed-position floating bar at the bottom center of the screen. Contains: 40×40 rounded cover art image, track title (with marquee animation if too long) + artist name, progress bar (range input mapped to currentTime/duration), play/pause toggle button, previous/next buttons, volume slider, and mute toggle icon. Built with shadcn/ui components for consistency with the rest of the app.

Note: The mini player must not use useAudio() or similar hooks that instantiate new audio elements — it reads from the shared Zustand store only. Any component reading the store gets the same playback state.

Track playlist

Data

Tracks fetched from a Supabase tracks table on mount (id, title, artist, cover_url, audio_url, duration_seconds, sort_order, is_active). The table supports dynamic playlist management via an admin panel. Alternatively, tracks can be hardcoded as a static array for simpler apps where the playlist never changes.

Note: Audio files stored in Supabase Storage public bucket. Typical ambient/background tracks are 3–5 MB each as 128 kbps MP3. A 10-track playlist at 4 MB average = 40 MB, well within Supabase's free 1 GB storage tier.

Autoplay gate

UI

Modern browsers require a user gesture before audio.play() is called. The autoplay gate is a subtle persistent hint (e.g. a pulsing music note icon in the corner) shown on first visit. The first click anywhere on the page triggers audio.play() inside the click handler. A try/catch around audio.play() catches NotAllowedError if autoplay fires before a gesture and sets state to 'waiting-for-interaction' without throwing.

Note: The 'click anywhere to start music' pattern is the most widely adopted autoplay solution and feels natural when presented as a UI element rather than a popup modal. Do not attempt to use the Audio API with no gesture — every major browser will block it.

Volume persistence

Data

Zustand persist middleware writes volume and isMuted to localStorage keys ('music_volume', 'music_muted') on every change. On mount, the store reads from localStorage and applies stored values to the audio element before the first track plays. The typeof window guard prevents SSR crashes.

Note: Only persist volume and muted state — not the current track index or playback position. Users expect their volume preference to be remembered, but not to resume mid-song from where they left off three days ago.

The data model

One table stores the track playlist with ordering and visibility controls. Run this in the Supabase SQL editor:

schema.sql
1create table public.tracks (
2 id uuid primary key default gen_random_uuid(),
3 title text not null,
4 artist text not null,
5 cover_url text,
6 audio_url text not null,
7 duration_seconds int,
8 sort_order int not null default 0,
9 is_active boolean not null default true,
10 created_at timestamptz not null default now()
11);
12
13alter table public.tracks enable row level security;
14
15create policy "Active tracks are publicly readable"
16 on public.tracks for select
17 using (is_active = true);
18
19create policy "Admins can manage all tracks"
20 on public.tracks for all
21 using (
22 auth.role() = 'authenticated' and exists (
23 select 1 from public.profiles
24 where id = auth.uid() and role = 'admin'
25 )
26 )
27 with check (
28 auth.role() = 'authenticated' and exists (
29 select 1 from public.profiles
30 where id = auth.uid() and role = 'admin'
31 )
32 );
33
34create index tracks_sort_active_idx
35 on public.tracks (is_active, sort_order)
36 where is_active = true;

Heads up: The partial index on (is_active, sort_order) keeps the playlist query fast as the tracks table grows. The public read policy requires no auth for playback — users don't need to sign in to hear music. Admin write access gates track management to authenticated admin users only.

Build it — pick your path

Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.

Full-stack, non-technical friendlyFit for this feature:

Lovable's best fit for this activity category. The HTML5 audio element, Zustand store, Supabase track table, and fixed mini-player are all well within Lovable's React + Tailwind capability. Supabase Storage for audio files is auto-wired. The autoplay gate is the one piece that requires explicit prompting.

Step by step

  1. 1Create a new Lovable project with Lovable Cloud enabled to auto-provision Supabase auth and Storage
  2. 2Upload your audio files to Supabase Storage via the Cloud tab and note the public URLs
  3. 3Paste the prompt below — Agent Mode will scaffold the root layout audio element, Zustand store, mini player, and Supabase tracks query
  4. 4After generation, verify the autoplay gate by opening the published URL in a new incognito window and confirming no audio starts automatically
  5. 5Test route change persistence by navigating between pages while music is playing — audio should continue without interruption
Paste into Lovable
Build a global background music player. Create a Zustand store (usePlayerStore) with fields: tracks (array of {id, title, artist, cover_url, audio_url, duration_seconds}), currentIndex (number, default 0), isPlaying (boolean), volume (number 0-1, default 0.7), isMuted (boolean), currentTime (number), duration (number). Actions: play(), pause(), next() (increment index modulo tracks.length), previous(), seek(seconds), setVolume(v), toggleMute(). Mount a single <audio> element in the root layout (app/layout.tsx) that is controlled by this store; useEffect syncs isPlaying to audio.play()/audio.pause(), volume/muted to audio.volume/audio.muted, and track changes to audio.src. Persist volume and isMuted to localStorage using Zustand persist middleware; wrap storage with: storage: typeof window !== 'undefined' ? createJSONStorage(() => localStorage) : undefined. Build a fixed bottom-center mini player bar with: 40x40 rounded cover art image, track title + artist, range input progress bar (value=currentTime, max=duration, onChange calls seek()), play/pause toggle button, previous/next buttons, volume slider, mute toggle. Fetch tracks from Supabase tracks table (is_active=true, ordered by sort_order) on mount and set them in the store. Autoplay gate: on app first load show a subtle pulsing music note icon in the bottom-right corner with tooltip 'Click to start music'; call audio.play() only inside a document click handler; wrap in try/catch and catch NotAllowedError by setting state to 'waiting-for-interaction'; show a toast 'Click anywhere to start the music' if caught. Handle states: loading-tracks / no-tracks / waiting-for-interaction / playing / paused / error.

Where this path bites

  • Lovable commonly omits the autoplay gate — explicitly include 'catch NotAllowedError and show a toast' in the prompt or the player shows 'playing' state with no audio
  • The Zustand persist SSR guard (typeof window check) is sometimes missed — if Next.js or SSR is involved, verify this is present or the server render crashes
  • Audio file CORS must be configured on Supabase Storage — test by opening the audio URL directly in a browser before wiring to the player

Third-party services you'll need

All audio processing is in-browser — the only cost is audio file hosting:

ServiceWhat it doesFree tierPaid from
Supabase StorageHosts audio files and cover art images for the track playlist1 GB storage freePro $25/mo includes 100 GB (approx)
Web Audio APIBrowser built-in audio engine — used for advanced features like crossfade and visualizationBrowser built-in, freeFree

Swipe the table sideways to see pricing.

What it costs to run

Drag through the tiers to see how your monthly bill scales with users — no surprises later.

Estimated monthly running cost

$0/mo

Audio files at 3–5 MB each, 10-track playlist = ~40 MB total storage — well within Supabase free tier. Bandwidth at 100 users streaming 4 MB tracks stays under free limits. All playback processing is in-browser.

Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.

What breaks when AI tools build this

The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.

Autoplay blocked silently — player shows playing, no audio

Symptom: Modern browsers (Chrome, Firefox, Safari) block audio.play() on any call that doesn't originate directly from a user gesture event handler. When the component mounts and immediately calls audio.play(), the browser blocks it silently — or throws NotAllowedError — but the Zustand store's isPlaying state is already true, so the mini-player shows a pause button while no audio plays. Users hear nothing and see no error.

Fix: Wrap every audio.play() call in a try/catch: try { await audio.play(); } catch (e) { if (e.name === 'NotAllowedError') { usePlayerStore.setState({ isPlaying: false, waitingForInteraction: true }); showToast('Click anywhere to start the music'); } }. Add a one-time document click listener that calls audio.play() inside the handler on first click. Remove the listener after the first successful play.

Zustand persist crashes on server-side rendering

Symptom: In Next.js App Router, the Zustand persist middleware with createJSONStorage(() => localStorage) runs during server-side rendering where window and localStorage are undefined. This throws ReferenceError: localStorage is not defined during the server render phase, crashing the page build. The error only appears in production SSR — development with fast refresh often masks it.

Fix: Guard the storage option with a typeof window check: storage: typeof window !== 'undefined' ? createJSONStorage(() => localStorage) : undefined. This makes the persist middleware skip storage entirely during SSR and hydrate from localStorage only in the browser. Alternatively, add 'use client' to a wrapper around the Zustand provider if using the React Context pattern.

Audio stops on route change

Symptom: If the <audio> element or the GlobalAudioPlayer component is mounted inside a page component (e.g. app/page.tsx or a specific route file), React unmounts it when navigating away and re-mounts on the new page — resetting the track to the beginning and creating an audible gap or restart. This is the most fundamental architecture mistake in background music player implementations.

Fix: Mount the <audio> element exclusively in app/layout.tsx (Next.js App Router) or the outermost index.tsx root (React SPA). The layout component persists across all route changes in App Router. Do not instantiate any audio element in route-level components. The MiniPlayer UI component can live anywhere because it only reads from the Zustand store — it doesn't create audio.

Volume resets to 100% on every track change

Symptom: When changing to a new track, the audio.src property is updated and in many implementations audio.volume is reset to 1.0 (the default) before the play() call. The user's preferred volume (stored in Zustand) is overwritten every time a song changes — a jarring jump to full volume mid-session.

Fix: Always set audio.volume = usePlayerStore.getState().volume immediately after updating audio.src and before calling audio.play(). Similarly, set audio.muted = usePlayerStore.getState().isMuted. A useEffect that depends on currentIndex is the right place: it runs after track change, applies stored volume, then plays. Do not set audio.volume anywhere except this single controlled useEffect.

Cover art CORS blocks canvas operations

Symptom: If cover art is ever rendered on a canvas — for example when implementing MediaSession API artwork or a visualizer background — cross-origin cover images from Supabase Storage throw a SecurityError if CORS is not configured. This doesn't affect <img> tag rendering but breaks any canvas.drawImage(coverArtImage) call.

Fix: For MediaSession API (OS-level now-playing display), use the navigator.mediaSession.metadata API directly with the cover art URL — it handles cross-origin images correctly without canvas. This is the preferred approach for OS media controls anyway. If canvas compositing is required, configure CORS on the Supabase Storage bucket and add crossOrigin='anonymous' to the cover art Image() object.

Best practices

1

Mount the <audio> element in the root layout component only — never in a page component, or route navigation will interrupt playback

2

Always wrap audio.play() in try/catch and handle NotAllowedError with a visible user hint — the browser will block it on first load in every major browser

3

Guard Zustand persist localStorage access with typeof window !== 'undefined' from the first line — SSR crashes only manifest in production and are hard to debug

4

Apply stored volume and muted state before every audio.play() call, not just on mount — track changes silently reset volume to 1.0 without this guard

5

Use the MediaSession API (navigator.mediaSession.metadata) for OS-level now-playing display — it handles cover art, artist, and title for lock screen and media key support with zero canvas complexity

6

Show a subtle persistent click-to-start hint on first visit rather than waiting for users to discover why no music plays — autoplay block is invisible without it

7

Store only volume and isMuted in localStorage — persisting the current track index and position often surprises users returning days later

When You Need Custom Development

Lovable handles ambient music players reliably. Certain audio features need the Web Audio API and custom engineering:

  • Crossfade between tracks using Web Audio API GainNode scheduling — a smooth 3-second crossfade requires AudioBufferSourceNode timing that the HTML5 <audio> element cannot provide
  • Real-time audio-reactive visuals (FFT AnalyserNode driving a canvas animation that responds to bass and treble frequencies)
  • User-created playlists with drag-to-reorder and personal queue management — requires a more complex data model and drag-and-drop UI beyond what Lovable generates
  • Integration with a music licensing API (Epidemic Sound, Artlist) for royalty-free track fetching, search, and licence management

RapidDev builds these features for production

Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.

Get a fixed-price quote

Frequently asked questions

How do I keep music playing when the user navigates to another page?

Mount the <audio> element in your root layout component — app/layout.tsx in Next.js App Router, or the top-level component that wraps all routes in a React SPA. This component never unmounts during navigation. Manage all playback state in a Zustand store so any component anywhere in the tree can read or update the player. The mini-player UI reads from the same store — it has no audio element of its own.

Why does my audio player stop on mobile when the screen locks?

iOS and Android require a background audio entitlement for audio to continue while the screen is locked. For web apps, audio typically pauses when the screen locks on iOS unless the Web Audio API AudioContext is used with the correct configuration. To maximize mobile background playback, add the MediaSession API: navigator.mediaSession.metadata = new MediaMetadata({...}) tells the OS this is media audio, which iOS and Android treat more favorably for background playback. Full background audio in a web app on iOS is not guaranteed — native apps have more reliable control.

How do I make music autoplay on page load?

You can't — reliably. All major browsers block audio.play() on page load without a prior user interaction. There is no workaround: muted autoplay is allowed but produces no music, and calling play() programmatically on page load throws NotAllowedError. The correct approach is an autoplay gate: show a subtle UI hint ('Click to start music') and call audio.play() inside the first click event handler. This pattern is universally accepted by users and browsers.

How do I persist the user's volume setting between visits?

Store volume and isMuted in localStorage via Zustand's persist middleware. On the next visit, the store reads these values and applies them to the audio element before the first track plays. The key implementation detail for Next.js: wrap the localStorage access with typeof window !== 'undefined' to prevent SSR crashes. Only persist volume and muted state — not the current track index or playback position.

Can I add crossfade between tracks in a web music player?

Yes, but it requires the Web Audio API rather than the HTML5 <audio> element. Create two AudioBufferSourceNode instances, each connected to a GainNode. When track N is near its end, begin ramping GainNode A down and GainNode B up over your crossfade duration (typically 2–4 seconds) using gainNode.gain.linearRampToValueAtTime(). Both nodes connect to the AudioContext's destination. This requires pre-loading the next track's audio buffer before the crossfade begins.

How do I show the current track in the browser tab or OS media controls?

Use the MediaSession API: navigator.mediaSession.metadata = new MediaMetadata({ title: track.title, artist: track.artist, artwork: [{ src: track.cover_url }] }). This displays now-playing information on the lock screen on mobile, in the OS media overlay on macOS, and in browser tab tooltips. Also implement the MediaSession action handlers for play, pause, previoustrack, and nexttrack so hardware media keys work correctly.

What's the cheapest way to host audio files for a background music player?

Supabase Storage free tier (1 GB) is free and suitable for small playlists — a 10-track playlist at 4 MB average is 40 MB, leaving 960 MB free. For high-traffic apps, Cloudflare R2 has no egress fees to the internet (files served to users cost $0 bandwidth), making it significantly cheaper than Supabase Storage beyond the free tier. At 10K users each streaming a few tracks per month, bandwidth becomes the dominant cost and R2 at $0.015/GB storage (but $0 egress) wins.

Should I use HTML5 Audio or the Web Audio API for background music?

HTML5 Audio (<audio> element) for almost all background music use cases — it's simpler, better supported, and sufficient for play/pause/volume/skip. The Web Audio API adds value only when you need crossfading (GainNode scheduling), frequency visualization (AnalyserNode FFT), or custom DSP effects (EQ, reverb via BiquadFilterNode). For a straightforward ambient playlist player, <audio> handles everything and Lovable can scaffold it correctly without manual Web Audio wiring.

RapidDev

Need this feature production-ready?

RapidDev builds a background music player into real apps — auth, database, payments — at $13K–$25K.

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.