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

- Tool: App Features
- Last updated: July 2026

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

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

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

## Data model

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

```sql
create table public.tracks (
  id uuid primary key default gen_random_uuid(),
  title text not null,
  artist text not null,
  cover_url text,
  audio_url text not null,
  duration_seconds int,
  sort_order int not null default 0,
  is_active boolean not null default true,
  created_at timestamptz not null default now()
);

alter table public.tracks enable row level security;

create policy "Active tracks are publicly readable"
  on public.tracks for select
  using (is_active = true);

create policy "Admins can manage all tracks"
  on public.tracks for all
  using (
    auth.role() = 'authenticated' and exists (
      select 1 from public.profiles
      where id = auth.uid() and role = 'admin'
    )
  )
  with check (
    auth.role() = 'authenticated' and exists (
      select 1 from public.profiles
      where id = auth.uid() and role = 'admin'
    )
  );

create index tracks_sort_active_idx
  on public.tracks (is_active, sort_order)
  where is_active = true;
```

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 paths

### Lovable — fit 5/10, 2–3 hours

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.

1. Create a new Lovable project with Lovable Cloud enabled to auto-provision Supabase auth and Storage
2. Upload your audio files to Supabase Storage via the Cloud tab and note the public URLs
3. Paste the prompt below — Agent Mode will scaffold the root layout audio element, Zustand store, mini player, and Supabase tracks query
4. After generation, verify the autoplay gate by opening the published URL in a new incognito window and confirming no audio starts automatically
5. Test route change persistence by navigating between pages while music is playing — audio should continue without interruption

Starter prompt:

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

Limitations:

- 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

### V0 — fit 4/10, 2–4 hours

V0 produces a polished mini-player UI and generates clean Zustand store boilerplate for Next.js. The root layout pattern for persistent audio works well in App Router. Two manual fixes typically needed: next/image for album art and the localStorage SSR guard.

1. Prompt V0 with the player spec below, explicitly requesting the Zustand persist SSR guard
2. Add Supabase environment variables in the Vars panel (NEXT_PUBLIC_SUPABASE_URL and anon key)
3. Run the tracks data model SQL in the Supabase SQL editor and insert test track rows
4. Verify that navigating between routes in the published app does not interrupt playback — confirm the <audio> element is in app/layout.tsx, not a page component
5. Check album art loading: if next/image is used, add your Supabase Storage domain to the images.domains list in next.config.js

Starter prompt:

```
Create a Next.js App Router background music player system. File 1: lib/playerStore.ts — Zustand store with fields: tracks (MusicTrack[]), currentIndex, isPlaying, volume (default 0.7), isMuted, currentTime, duration. Actions: play, pause, next, previous, seek(s), setVolume(v), toggleMute. Persist only volume and isMuted to localStorage with createJSONStorage; guard with: storage: typeof window !== 'undefined' ? createJSONStorage(() => localStorage) : undefined. File 2: components/GlobalAudioPlayer.tsx — 'use client' component that mounts a single <audio> element; useEffect syncs store state to audio properties and calls audio.play()/pause() accordingly; audio.ontimeupdate sets currentTime in store; audio.onended calls next(); try/catch audio.play() and on NotAllowedError show a toast 'Click anywhere to start music'. File 3: components/MiniPlayer.tsx — fixed bottom-center bar with cover art (<img> not next/image), title, artist, progress range input, play/pause, prev/next, volume slider, mute toggle, all reading from usePlayerStore. File 4: Update app/layout.tsx to render GlobalAudioPlayer and MiniPlayer inside the body so they persist across all routes. Fetch tracks from Supabase 'tracks' table on GlobalAudioPlayer mount and populate the store.
```

Limitations:

- V0 may generate next/image for album art thumbnails — swap to a plain <img> tag to avoid domain whitelist issues with Supabase Storage URLs
- The typeof window SSR guard is sometimes omitted from V0's Zustand persist configuration — add it manually or the Next.js server render will throw ReferenceError
- V0 does not auto-provision Supabase — insert track rows manually and verify the audio_url column contains direct Supabase Storage HTTPS URLs before testing

### Custom — fit 3/10, 3–5 days

Custom dev gives full access to the Web Audio API for crossfading between tracks, FFT visualization, and equalizer controls. For a simple ambient music player, this is overkill — Lovable handles 90% of the use case adequately. Choose custom when audio quality features are a product differentiator.

1. Replace the HTML5 <audio> element with Web Audio API AudioContext + GainNode for crossfade scheduling between tracks using AudioBufferSourceNode
2. Add an AnalyserNode for FFT data driving a canvas-based audio reactive visualization
3. Implement drag-to-reorder playlist using @dnd-kit/core for user-created queues
4. Connect to a music licensing API (Epidemic Sound, Artlist) for royalty-free track fetching and management
5. Add MediaSession API integration for OS-level now-playing display and media key support (keyboard media controls, lock screen on mobile)

Limitations:

- Web Audio API crossfading requires AudioBufferSourceNode which loads full tracks into memory — unsuitable for tracks over 10 minutes
- Significantly more complexity for a feature where the simple HTML5 audio approach is functionally equivalent for most users

## Gotchas

- **Autoplay blocked silently — player shows playing, no audio** — 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** — 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** — 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** — 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** — 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

- Mount the <audio> element in the root layout component only — never in a page component, or route navigation will interrupt playback
- 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
- Guard Zustand persist localStorage access with typeof window !== 'undefined' from the first line — SSR crashes only manifest in production and are hard to debug
- 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
- 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
- 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
- Store only volume and isMuted in localStorage — persisting the current track index and position often surprises users returning days later

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

---

Source: https://www.rapidevelopers.com/app-features/background-music-player
© RapidDev — https://www.rapidevelopers.com/app-features/background-music-player
