Build a Spotify Clone with Next.js

Build a Spotify-style music player app with persistent audio across pages.

Introduction

30 Second Summary

Think about how Spotify keeps your music playing even when you click between pages. The song never skips or restarts.

In this project, you will build a Spotify-inspired music player using Next.js 16. You'll create a dark-themed interface with album browsing, track listings, and a persistent audio player that keeps playing as you navigate between pages.

What You'll Build

A Spotify-style music player built with Next.js 16 and Tailwind CSS, featuring persistent layouts, global state management, and browser-native audio playback.

By the end of this project, you'll have:

  • A multi-page app with sidebar navigation, an album grid, and track listing pages built using the Next.js App Router.
  • A persistent audio player fixed to the bottom of the screen that keeps playing across page navigations, powered by React Context for global state.
  • Functional playback controls including play/pause, next/previous, a progress bar, and volume slider using the HTML5 Audio API.
  • Secret Mission: a real-time search feature that filters tracks and albums as you type.

Are there any prerequisites?

You'll need a basic understanding of HTML, CSS, and JavaScript. No React experience is needed. This project introduces React concepts step by step as you build.

Before We Start

Before We Start...

Set Up Your Next.js Project

Before you can build anything, you need a working development environment and a fresh project to build in. Next.js is the React framework you'll use for this Spotify clone.

In this step, you'll install Node.js, scaffold a new Next.js 16 project, and add the icon library that powers the player controls. By the end, your dev server will be running and you'll be ready to build.

In this step, get ready to:
  • Install Node.js and scaffold a Next.js 16 project with JavaScript and Tailwind CSS.
  • Install the lucide-react icon library for player controls and navigation.
  • Start the development server and confirm the default page loads.
Install Node.js and scaffold your Next.js project

Next.js runs on Node.js, a JavaScript runtime that executes code outside the browser. Next.js 16 requires Node.js version 20.9.0 or higher.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Node.js version by running this command:
node --version

✔️ I see version 20 or higher

Your Node.js version is compatible with Next.js 16. You're ready to scaffold the project.

ⓧ I see an older version

You have Node.js installed, but it's too old for Next.js 16. You need version 20.9.0 or higher.

  • Go to https://nodejs.org and download the LTS version (20+).
  • Run the installer and follow the prompts.
  • Close and reopen your terminal, then run node --version again to confirm the new version.

macOS users with an older default Node

If you previously installed Node.js through Homebrew or your system package manager, the old version may shadow the new install. After installing the latest LTS, close all terminal windows and open a fresh one before checking the version again.

ⓧ Command not found

Node.js is not installed on your machine yet. Let's install it.

  • Go to https://nodejs.org and download the LTS version (20+).
  • Run the installer and follow the prompts to complete the installation.
  • Close and reopen your terminal, then run node --version to confirm it shows v20 or higher.

Now you'll create the project folder on your Desktop so it's easy to find.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Next.js project by running this command:
npx create-next-app@latest spotify-clone --js

What will I see in my terminal?

Your terminal may ask to install the create-next-app package. Press Enter or type y to confirm.

The CLI may then ask if you want to use the recommended defaults. Accept the defaults by pressing Enter. This gives you Tailwind CSS, ESLint, and the App Router out of the box.

What does this command do?

The --js flag tells Next.js to use plain JavaScript instead of TypeScript. This keeps the code simpler so you can focus on learning React and Next.js concepts without extra type annotations.

  • Wait for the installation to finish. You'll see a success message when it's done.
  • Move into your new project folder by running:
cd spotify-clone

Seeing a permission error or timeout?

If you get a permission error, try running the command with sudo on macOS/Linux. If the command times out, check your internet connection and try again.

help me fix an error when running create-next-app

Now open the project in VS Code so you can start editing files.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the spotify-clone folder, then click Open.

Your project will use images from an external source later. You need to configure Next.js to allow loading images from that domain.

  • In the VS Code Explorer sidebar on the left, click next.config.mjs to open it.
  • Select all the existing content in the file (Cmd+A on macOS, Ctrl+A on Windows) and replace it with this code:
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "picsum.photos",
      },
    ],
  },
};

export default nextConfig;

What does this code do?

Next.js blocks external images by default for security. The remotePatterns setting tells Next.js it's safe to load images from picsum.photos. You'll use this service for album cover art placeholders.

  • Save the file with Cmd+S (macOS) or Ctrl+S (Windows).
Install the lucide-react icon library

Your music player needs icons for play, pause, skip, and volume controls. Lucide is a lightweight icon library that only ships the icons you import, keeping your app fast.

  • Open the VS Code terminal by pressing Ctrl+` (backtick key, same on both macOS and Windows).
  • Install lucide-react by running this command:
npm install lucide-react
  • Wait for the installation to complete. You should see a confirmation message with the number of packages added.

Seeing npm ERR?

Make sure your terminal is inside the spotify-clone folder. If the prompt shows a different directory, run cd ~/Desktop/spotify-clone first and try again.

help me fix an npm install error

Start the development server

Next.js includes a development server that watches your files and refreshes the browser automatically when you make changes. Let's start it up to confirm everything works.

  • Start the development server by running:
npm run dev
  • Open your browser and go to http://localhost:3000.

You should see the default Next.js welcome page with the Next.js logo and links to documentation.

Page not loading?

Check your terminal for error messages. If you see a port conflict, another app may already be using port 3000. Stop the other process or close that terminal window, then try npm run dev again.

help me debug my Next.js dev server

✔️ Awesome, I've got everything!

Great. Double check you've saved next.config.mjs and your dev server is running at http://localhost:3000.

ⓧ I'd like to double check the full code

Here's what your next.config.mjs should look like:

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "picsum.photos",
      },
    ],
  },
};

export default nextConfig;

Your development environment is ready. Next up, you'll build the app's skeleton with a sidebar, main content area, and a fixed bottom player bar.

Build the App Layout with Sidebar and Player

Your Next.js project is scaffolded and your dev server is running. Now it's time to build the visual structure of the app.

Every music streaming app has the same three-zone layout: a sidebar for navigation, a main content area that changes between pages, and a persistent player bar at the bottom. In this step, you'll recreate this structure using Next.js shared layouts and Tailwind CSS utility classes.

In this step, get ready to:
  • Create the root layout with a dark theme, sidebar, main content area, and fixed bottom player bar.
  • Build a Sidebar component with navigation links using Lucide icons.
  • Build a placeholder Player component fixed to the bottom of the viewport.
Set up global styles and the root layout

The App Router in Next.js uses a special layout.jsx file that wraps every page. Components placed in this layout never unmount when you navigate between routes. This is exactly how Spotify keeps music playing as you browse different pages.

  • In VS Code, open app/globals.css from the Explorer sidebar. Replace its entire contents with this code:
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  * {
    box-sizing: border-box;
  }

  /* Custom styling for the audio player's range inputs (progress bar, volume) */
  input[type="range"] {
    -webkit-appearance: none;
    appearance: none;
    height: 4px;
    border-radius: 2px;
    background: #4d4d4d;
    cursor: pointer;
  }

  /* White circular thumb for range sliders */
  input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: white;
  }
}

What do these styles do?

  • The three @tailwind directives inject Tailwind's base reset, component classes, and utility classes into your stylesheet.
  • The @layer base block adds custom styles at the lowest specificity level so Tailwind utilities can still override them.
  • The input[type="range"] rules strip the browser's default slider appearance and replace it with a slim dark track and white circular thumb. These will style the progress bar and volume slider in the player.
  • Save app/globals.css.
  • You won't see a visual change yet. These styles prepare the audio player's range inputs for later steps.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  * {
    box-sizing: border-box;
  }

  input[type="range"] {
    -webkit-appearance: none;
    appearance: none;
    height: 4px;
    border-radius: 2px;
    background: #4d4d4d;
    cursor: pointer;
  }

  input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: white;
  }
}
  • Now open app/layout.jsx and replace its entire contents with this code:
import { Inter } from "next/font/google";
import "./globals.css";
import Sidebar from "@/components/Sidebar";
import Player from "@/components/Player";
import { PlayerProvider } from "@/context/PlayerContext";

// Load the Inter font from Google Fonts
const inter = Inter({ subsets: ["latin"] });

// Page metadata shown in the browser tab
export const metadata = {
  title: "Spotify Clone",
  description: "A Spotify clone built with Next.js",
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={`${inter.className} bg-black text-white`}>
        {/* PlayerProvider wraps everything so any component can access playback state */}
        <PlayerProvider>
          {/* Full-height flex column: top section + bottom player bar */}
          <div className="flex h-screen flex-col">
            {/* Top section: sidebar + scrollable main content */}
            <div className="flex flex-1 overflow-hidden">
              <Sidebar />
              <main className="flex-1 overflow-y-auto bg-gradient-to-b from-zinc-800 to-black p-6">
                {children}
              </main>
            </div>
            {/* Player bar stays fixed at the bottom across all pages */}
            <Player />
          </div>
        </PlayerProvider>
      </body>
    </html>
  );
}

What does this layout do?

  • The RootLayout function wraps every page in the app. Because it never unmounts during navigation, the Player component stays alive and keeps audio playing as users browse.
  • The flex h-screen flex-col container creates a full-height column. The top section (sidebar + content) takes all available space, and the Player sticks at the bottom.
  • The PlayerProvider wraps everything so any component can read and update the current track, play state, and volume. You'll build this context in the next step.
  • The {children} placeholder is where each page's content renders. When you navigate to a different route, only this area changes.
  • Save app/layout.jsx.
  • Your app will show an error in the browser right now. That's expected. The layout imports Sidebar, Player, and PlayerContext which don't exist yet.

Seeing an error in the browser? Don't worry.

The layout references three files that you haven't created yet: components/Sidebar.jsx, components/Player.jsx, and context/PlayerContext.jsx. You'll create the first two in this step and the context file in the next step.

The error will disappear once all three files are in place.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on to create the Sidebar.

ⓧ I'd like to double check the full code

import { Inter } from "next/font/google";
import "./globals.css";
import Sidebar from "@/components/Sidebar";
import Player from "@/components/Player";
import { PlayerProvider } from "@/context/PlayerContext";

const inter = Inter({ subsets: ["latin"] });

export const metadata = {
  title: "Spotify Clone",
  description: "A Spotify clone built with Next.js",
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={`${inter.className} bg-black text-white`}>
        <PlayerProvider>
          <div className="flex h-screen flex-col">
            <div className="flex flex-1 overflow-hidden">
              <Sidebar />
              <main className="flex-1 overflow-y-auto bg-gradient-to-b from-zinc-800 to-black p-6">
                {children}
              </main>
            </div>
            <Player />
          </div>
        </PlayerProvider>
      </body>
    </html>
  );
}

Why use a shared layout for the player?

In a traditional multi-page app, navigating to a new page destroys everything on screen and rebuilds it from scratch. That would stop any playing audio dead.

Next.js shared layouts solve this by keeping certain components mounted permanently. The Player lives in layout.jsx so it never unmounts. Only the {children} area swaps out during navigation. This is the same architecture real Spotify uses.

Build the Sidebar component

The sidebar gives users a fixed navigation panel on the left side of the screen. It uses the Link component from Next.js for client-side navigation and icons from Lucide React.

  • Create a new folder called components inside your spotify-clone project folder. In VS Code, right-click in the Explorer sidebar and select New Folder.
  • Inside the components folder, create a new file called Sidebar.jsx. Right-click the components folder and select New File.
  • Paste the following code into components/Sidebar.jsx:
import Link from "next/link";
import { Home, Search, Library } from "lucide-react";

export default function Sidebar() {
  return (
    // Hidden on mobile, visible as a 64-unit wide column on medium+ screens
    <aside className="hidden w-64 flex-col gap-2 bg-black p-4 md:flex">
      <nav className="rounded-lg bg-zinc-900 p-4">
        <ul className="space-y-4">
          <li>
            {/* Home link - active style (white text) */}
            <Link
              href="/"
              className="flex items-center gap-4 font-semibold text-white hover:text-white"
            >
              <Home size={24} />
              Home
            </Link>
          </li>
          <li>
            {/* Search link - inactive style (gray text, white on hover) */}
            <Link
              href="/search"
              className="flex items-center gap-4 font-semibold text-zinc-400 hover:text-white"
            >
              <Search size={24} />
              Search
            </Link>
          </li>
          <li>
            {/* Library link */}
            <Link
              href="/"
              className="flex items-center gap-4 font-semibold text-zinc-400 hover:text-white"
            >
              <Library size={24} />
              Your Library
            </Link>
          </li>
        </ul>
      </nav>
    </aside>
  );
}

What does this component do?

  • The Link component from Next.js handles client-side navigation. Clicking a link swaps the page content without a full browser reload, which keeps the player running.
  • The Home, Search, and Library are icon components imported from lucide-react. The size={24} prop sets their pixel dimensions.
  • The hidden md:flex classes hide the sidebar on small screens and show it as a flex column on medium screens and larger. This is a responsive design pattern.
  • Save components/Sidebar.jsx.
  • The sidebar won't appear in the browser yet. One more component to create before the layout can render.

Seeing a red squiggly under the import?

If VS Code shows an error on import { Home, Search, Library } from "lucide-react", confirm that you installed lucide-react in Step 1. Check your package.json for a lucide-react entry under dependencies.

help me fix a lucide-react import error in my Sidebar component

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before creating the Player.

ⓧ I'd like to double check the full code

import Link from "next/link";
import { Home, Search, Library } from "lucide-react";

export default function Sidebar() {
  return (
    <aside className="hidden w-64 flex-col gap-2 bg-black p-4 md:flex">
      <nav className="rounded-lg bg-zinc-900 p-4">
        <ul className="space-y-4">
          <li>
            <Link
              href="/"
              className="flex items-center gap-4 font-semibold text-white hover:text-white"
            >
              <Home size={24} />
              Home
            </Link>
          </li>
          <li>
            <Link
              href="/search"
              className="flex items-center gap-4 font-semibold text-zinc-400 hover:text-white"
            >
              <Search size={24} />
              Search
            </Link>
          </li>
          <li>
            <Link
              href="/"
              className="flex items-center gap-4 font-semibold text-zinc-400 hover:text-white"
            >
              <Library size={24} />
              Your Library
            </Link>
          </li>
        </ul>
      </nav>
    </aside>
  );
}
Build the placeholder Player component

The Player component will eventually contain full audio controls. For now, you'll create a simple placeholder that reserves the space at the bottom of the viewport.

  • Inside the components folder, create a new file called Player.jsx. Right-click the components folder in the Explorer sidebar and select New File.
  • Paste the following code into components/Player.jsx:
"use client";

export default function Player() {
  return (
    // Fixed-height bar at the bottom with a subtle top border
    <div className="flex h-20 items-center border-t border-zinc-800 bg-black px-4">
      <p className="text-zinc-400 text-sm">No track selected</p>
    </div>
  );
}

What does this code do?

  • The "use client" directive marks this as a client component. It tells Next.js this component needs browser features like state and event handlers. You'll add those in Step 6.
  • The h-20 class gives the bar a fixed 80px height. The border-t border-zinc-800 adds a subtle dark line separating it from the content above.
  • For now it just displays a placeholder message. In Step 6, you'll replace this with full playback controls.
  • Save components/Player.jsx.
  • In VS Code's Explorer sidebar, expand the components folder. You should see both Sidebar.jsx and Player.jsx listed.

Don't see the components folder?

Make sure you created the components folder at the root of your spotify-clone project (the same level as the app folder). If you accidentally nested it inside app/, drag it up one level.

help me fix my folder structure for the Spotify clone components

✔️ Awesome, I've got everything!

Great. Double check you've saved all files before moving on.

ⓧ I'd like to double check the full code

"use client";

export default function Player() {
  return (
    <div className="flex h-20 items-center border-t border-zinc-800 bg-black px-4">
      <p className="text-zinc-400 text-sm">No track selected</p>
    </div>
  );
}

Why is the browser still showing an error?

Your layout imports PlayerProvider from @/context/PlayerContext, but that file doesn't exist yet. You'll create it in the next step. Once the context is in place, the app will render with the sidebar on the left, the main content area in the center, and the player bar at the bottom.

Your layout skeleton is in place. Next up, you'll create the PlayerContext that wires everything together and makes the app render for the first time.

Create Music Data and Player Context

Your layout is ready with a sidebar, a main content area, and a placeholder player bar. Right now, though, your app is showing an error because layout.jsx imports a PlayerProvider that doesn't exist yet.

Before building any pages, you need two things: sample music data for the app to display, and a way to share playback state (which track is playing, volume, queue) across every component. React Context solves the state-sharing problem by letting you pass data to any component without threading props through every level.

In this step, get ready to:
  • Create a data file with sample albums and tracks.
  • Build a PlayerContext that manages playback state across the entire app.
  • Resolve the app error by providing the PlayerProvider that the layout already imports.
Create the music data file

Every music app needs a catalog. You'll create a centralized data file that holds album and track information. All your components will import from this single source.

  • In the VS Code Explorer sidebar, create a new folder called lib at the root of your project (at the same level as app and components).
  • Inside the lib folder, create a new file called data.js.
  • Paste the following code into lib/data.js:
export const albums = [
  {
    id: "chill-vibes",
    title: "Chill Vibes",
    artist: "Lo-Fi Beats",
    cover: "https://picsum.photos/seed/album1/300/300",
    tracks: [
      {
        id: "track-1",
        title: "Rainy Afternoon",
        artist: "Lo-Fi Beats",
        duration: "3:24",
        src: "/audio/sample1.mp3",
        cover: "https://picsum.photos/seed/album1/300/300",
      },
      {
        id: "track-2",
        title: "Coffee Shop",
        artist: "Lo-Fi Beats",
        duration: "2:58",
        src: "/audio/sample2.mp3",
        cover: "https://picsum.photos/seed/album1/300/300",
      },
      {
        id: "track-3",
        title: "Late Night Study",
        artist: "Lo-Fi Beats",
        duration: "4:12",
        src: "/audio/sample3.mp3",
        cover: "https://picsum.photos/seed/album1/300/300",
      },
    ],
  },
  {
    id: "electric-dreams",
    title: "Electric Dreams",
    artist: "Synthwave Runner",
    cover: "https://picsum.photos/seed/album2/300/300",
    tracks: [
      {
        id: "track-4",
        title: "Neon Highways",
        artist: "Synthwave Runner",
        duration: "3:45",
        src: "/audio/sample1.mp3",
        cover: "https://picsum.photos/seed/album2/300/300",
      },
      {
        id: "track-5",
        title: "Midnight Drive",
        artist: "Synthwave Runner",
        duration: "4:02",
        src: "/audio/sample2.mp3",
        cover: "https://picsum.photos/seed/album2/300/300",
      },
      {
        id: "track-6",
        title: "Digital Sunset",
        artist: "Synthwave Runner",
        duration: "3:33",
        src: "/audio/sample3.mp3",
        cover: "https://picsum.photos/seed/album2/300/300",
      },
    ],
  },
  {
    id: "acoustic-morning",
    title: "Acoustic Morning",
    artist: "Guitar Tales",
    cover: "https://picsum.photos/seed/album3/300/300",
    tracks: [
      {
        id: "track-7",
        title: "Sunrise Strings",
        artist: "Guitar Tales",
        duration: "3:11",
        src: "/audio/sample1.mp3",
        cover: "https://picsum.photos/seed/album3/300/300",
      },
      {
        id: "track-8",
        title: "Morning Dew",
        artist: "Guitar Tales",
        duration: "2:45",
        src: "/audio/sample2.mp3",
        cover: "https://picsum.photos/seed/album3/300/300",
      },
      {
        id: "track-9",
        title: "Campfire Song",
        artist: "Guitar Tales",
        duration: "4:30",
        src: "/audio/sample3.mp3",
        cover: "https://picsum.photos/seed/album3/300/300",
      },
    ],
  },
  {
    id: "urban-beats",
    title: "Urban Beats",
    artist: "City Pulse",
    cover: "https://picsum.photos/seed/album4/300/300",
    tracks: [
      {
        id: "track-10",
        title: "Street Lights",
        artist: "City Pulse",
        duration: "3:55",
        src: "/audio/sample1.mp3",
        cover: "https://picsum.photos/seed/album4/300/300",
      },
      {
        id: "track-11",
        title: "Subway Rhythm",
        artist: "City Pulse",
        duration: "3:20",
        src: "/audio/sample2.mp3",
        cover: "https://picsum.photos/seed/album4/300/300",
      },
      {
        id: "track-12",
        title: "Rooftop Groove",
        artist: "City Pulse",
        duration: "4:08",
        src: "/audio/sample3.mp3",
        cover: "https://picsum.photos/seed/album4/300/300",
      },
    ],
  },
  {
    id: "ocean-waves",
    title: "Ocean Waves",
    artist: "Ambient Coast",
    cover: "https://picsum.photos/seed/album5/300/300",
    tracks: [
      {
        id: "track-13",
        title: "Tidal Flow",
        artist: "Ambient Coast",
        duration: "5:12",
        src: "/audio/sample1.mp3",
        cover: "https://picsum.photos/seed/album5/300/300",
      },
      {
        id: "track-14",
        title: "Coral Dreams",
        artist: "Ambient Coast",
        duration: "4:44",
        src: "/audio/sample2.mp3",
        cover: "https://picsum.photos/seed/album5/300/300",
      },
      {
        id: "track-15",
        title: "Horizon Line",
        artist: "Ambient Coast",
        duration: "3:58",
        src: "/audio/sample3.mp3",
        cover: "https://picsum.photos/seed/album5/300/300",
      },
    ],
  },
];

What does this data contain?

The albums array holds 5 album objects. Each album has an id (used in URLs), a title, an artist, a cover image URL from picsum.photos, and a tracks array.

Each track object includes its own id, title, artist, duration (for display), src (the audio file path), and cover (so the player bar can show the track's album art).

Add your own audio files

The src paths point to /audio/sample1.mp3 through /audio/sample3.mp3. Create a public/audio folder and drop any MP3 files in there with those names. You can download royalty-free samples from any free music site.

  • Save lib/data.js.

You won't see a visual change yet. The next substep creates the PlayerContext, and future steps will use this data to render album cards and track lists.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

export const albums = [
  {
    id: "chill-vibes",
    title: "Chill Vibes",
    artist: "Lo-Fi Beats",
    cover: "https://picsum.photos/seed/album1/300/300",
    tracks: [
      {
        id: "track-1",
        title: "Rainy Afternoon",
        artist: "Lo-Fi Beats",
        duration: "3:24",
        src: "/audio/sample1.mp3",
        cover: "https://picsum.photos/seed/album1/300/300",
      },
      {
        id: "track-2",
        title: "Coffee Shop",
        artist: "Lo-Fi Beats",
        duration: "2:58",
        src: "/audio/sample2.mp3",
        cover: "https://picsum.photos/seed/album1/300/300",
      },
      {
        id: "track-3",
        title: "Late Night Study",
        artist: "Lo-Fi Beats",
        duration: "4:12",
        src: "/audio/sample3.mp3",
        cover: "https://picsum.photos/seed/album1/300/300",
      },
    ],
  },
  {
    id: "electric-dreams",
    title: "Electric Dreams",
    artist: "Synthwave Runner",
    cover: "https://picsum.photos/seed/album2/300/300",
    tracks: [
      {
        id: "track-4",
        title: "Neon Highways",
        artist: "Synthwave Runner",
        duration: "3:45",
        src: "/audio/sample1.mp3",
        cover: "https://picsum.photos/seed/album2/300/300",
      },
      {
        id: "track-5",
        title: "Midnight Drive",
        artist: "Synthwave Runner",
        duration: "4:02",
        src: "/audio/sample2.mp3",
        cover: "https://picsum.photos/seed/album2/300/300",
      },
      {
        id: "track-6",
        title: "Digital Sunset",
        artist: "Synthwave Runner",
        duration: "3:33",
        src: "/audio/sample3.mp3",
        cover: "https://picsum.photos/seed/album2/300/300",
      },
    ],
  },
  {
    id: "acoustic-morning",
    title: "Acoustic Morning",
    artist: "Guitar Tales",
    cover: "https://picsum.photos/seed/album3/300/300",
    tracks: [
      {
        id: "track-7",
        title: "Sunrise Strings",
        artist: "Guitar Tales",
        duration: "3:11",
        src: "/audio/sample1.mp3",
        cover: "https://picsum.photos/seed/album3/300/300",
      },
      {
        id: "track-8",
        title: "Morning Dew",
        artist: "Guitar Tales",
        duration: "2:45",
        src: "/audio/sample2.mp3",
        cover: "https://picsum.photos/seed/album3/300/300",
      },
      {
        id: "track-9",
        title: "Campfire Song",
        artist: "Guitar Tales",
        duration: "4:30",
        src: "/audio/sample3.mp3",
        cover: "https://picsum.photos/seed/album3/300/300",
      },
    ],
  },
  {
    id: "urban-beats",
    title: "Urban Beats",
    artist: "City Pulse",
    cover: "https://picsum.photos/seed/album4/300/300",
    tracks: [
      {
        id: "track-10",
        title: "Street Lights",
        artist: "City Pulse",
        duration: "3:55",
        src: "/audio/sample1.mp3",
        cover: "https://picsum.photos/seed/album4/300/300",
      },
      {
        id: "track-11",
        title: "Subway Rhythm",
        artist: "City Pulse",
        duration: "3:20",
        src: "/audio/sample2.mp3",
        cover: "https://picsum.photos/seed/album4/300/300",
      },
      {
        id: "track-12",
        title: "Rooftop Groove",
        artist: "City Pulse",
        duration: "4:08",
        src: "/audio/sample3.mp3",
        cover: "https://picsum.photos/seed/album4/300/300",
      },
    ],
  },
  {
    id: "ocean-waves",
    title: "Ocean Waves",
    artist: "Ambient Coast",
    cover: "https://picsum.photos/seed/album5/300/300",
    tracks: [
      {
        id: "track-13",
        title: "Tidal Flow",
        artist: "Ambient Coast",
        duration: "5:12",
        src: "/audio/sample1.mp3",
        cover: "https://picsum.photos/seed/album5/300/300",
      },
      {
        id: "track-14",
        title: "Coral Dreams",
        artist: "Ambient Coast",
        duration: "4:44",
        src: "/audio/sample2.mp3",
        cover: "https://picsum.photos/seed/album5/300/300",
      },
      {
        id: "track-15",
        title: "Horizon Line",
        artist: "Ambient Coast",
        duration: "3:58",
        src: "/audio/sample3.mp3",
        cover: "https://picsum.photos/seed/album5/300/300",
      },
    ],
  },
];
Build the PlayerContext

In React, when multiple components need access to the same data, you use a Context. Your player bar, track list, and album pages all need to know which track is playing and control playback. A PlayerContext gives them a shared source of truth.

  • In the VS Code Explorer sidebar, create a new folder called context at the root of your project (same level as app, components, and lib).
  • Inside the context folder, create a new file called PlayerContext.jsx.
  • Paste the following code into context/PlayerContext.jsx:
"use client";

import { createContext, useContext, useState } from "react";

const PlayerContext = createContext(undefined);

export function PlayerProvider({ children }) {
  // State for the currently selected track
  const [currentTrack, setCurrentTrack] = useState(null);
  // Whether audio is currently playing
  const [isPlaying, setIsPlaying] = useState(false);
  // Volume level from 0 to 1
  const [volume, setVolume] = useState(0.7);
  // The list of tracks available to play next/previous
  const [queue, setQueue] = useState([]);

  // Start playing a specific track and set the queue to its album
  function playTrack(track, trackList) {
    setCurrentTrack(track);
    setQueue(trackList);
    setIsPlaying(true);
  }

  // Toggle between play and pause
  function togglePlay() {
    if (currentTrack) {
      setIsPlaying(!isPlaying);
    }
  }

  // Skip to the next track in the queue (loops around)
  function nextTrack() {
    if (queue.length === 0 || !currentTrack) return;
    const currentIndex = queue.findIndex((t) => t.id === currentTrack.id);
    const nextIndex = (currentIndex + 1) % queue.length;
    setCurrentTrack(queue[nextIndex]);
    setIsPlaying(true);
  }

  // Go back to the previous track in the queue (loops around)
  function prevTrack() {
    if (queue.length === 0 || !currentTrack) return;
    const currentIndex = queue.findIndex((t) => t.id === currentTrack.id);
    const prevIndex = (currentIndex - 1 + queue.length) % queue.length;
    setCurrentTrack(queue[prevIndex]);
    setIsPlaying(true);
  }

  return (
    <PlayerContext.Provider
      value={{
        currentTrack,
        isPlaying,
        volume,
        queue,
        playTrack,
        togglePlay,
        nextTrack,
        prevTrack,
        setVolume,
      }}
    >
      {children}
    </PlayerContext.Provider>
  );
}

export function usePlayer() {
  const context = useContext(PlayerContext);
  if (!context) {
    throw new Error("usePlayer must be used within a PlayerProvider");
  }
  return context;
}

What does this code do?

"use client" at the top tells Next.js this file uses browser-only features like useState. Without it, Next.js would try to run this code on the server, where React hooks don't work.

The PlayerProvider component wraps your app and holds four pieces of state: currentTrack (the active song), isPlaying (play/pause toggle), volume (0 to 1), and queue (the track list for next/previous).

The functions (playTrack, togglePlay, nextTrack, prevTrack) update that state. The usePlayer() hook is a shortcut for any component that needs to read or control playback.

Why use Context instead of passing props?

Without Context, you would need to pass the current track and playback functions from the layout down through every page and into every component manually. Context lets any component grab what it needs directly with usePlayer(), no matter how deeply nested it is.

  • Save context/PlayerContext.jsx.
  • Check your browser at localhost:3000. The error should be gone, and you should see your dark layout with the sidebar and the player bar showing "No track selected" at the bottom.

Still seeing an error?

  • Make sure the file is saved at exactly context/PlayerContext.jsx (not contexts/ or Context/). The import in layout.jsx uses @/context/PlayerContext, so the folder and filename must match exactly.
  • Check that PlayerProvider is exported with the export keyword at the start of the function declaration. A missing export means the layout can't find it.
  • Confirm "use client" is the very first line in the file, with nothing before it (no blank lines, no comments).

Help me fix my PlayerContext error

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

"use client";

import { createContext, useContext, useState } from "react";

const PlayerContext = createContext(undefined);

export function PlayerProvider({ children }) {
  const [currentTrack, setCurrentTrack] = useState(null);
  const [isPlaying, setIsPlaying] = useState(false);
  const [volume, setVolume] = useState(0.7);
  const [queue, setQueue] = useState([]);

  function playTrack(track, trackList) {
    setCurrentTrack(track);
    setQueue(trackList);
    setIsPlaying(true);
  }

  function togglePlay() {
    if (currentTrack) {
      setIsPlaying(!isPlaying);
    }
  }

  function nextTrack() {
    if (queue.length === 0 || !currentTrack) return;
    const currentIndex = queue.findIndex((t) => t.id === currentTrack.id);
    const nextIndex = (currentIndex + 1) % queue.length;
    setCurrentTrack(queue[nextIndex]);
    setIsPlaying(true);
  }

  function prevTrack() {
    if (queue.length === 0 || !currentTrack) return;
    const currentIndex = queue.findIndex((t) => t.id === currentTrack.id);
    const prevIndex = (currentIndex - 1 + queue.length) % queue.length;
    setCurrentTrack(queue[prevIndex]);
    setIsPlaying(true);
  }

  return (
    <PlayerContext.Provider
      value={{
        currentTrack,
        isPlaying,
        volume,
        queue,
        playTrack,
        togglePlay,
        nextTrack,
        prevTrack,
        setVolume,
      }}
    >
      {children}
    </PlayerContext.Provider>
  );
}

export function usePlayer() {
  const context = useContext(PlayerContext);
  if (!context) {
    throw new Error("usePlayer must be used within a PlayerProvider");
  }
  return context;
}
Verify the provider is connected to your layout

Your layout.jsx already imports PlayerProvider and wraps the entire app in it. Now that the context file exists, that connection is live.

  • Open app/layout.jsx and confirm line 5 reads:
import { PlayerProvider } from "@/context/PlayerContext";
  • Scroll down and confirm the <PlayerProvider> tag wraps all the layout content inside <body>.

Why does the provider wrap everything?

Because PlayerProvider lives in the root layout, it stays mounted across all page navigations. This is the key architectural choice that lets audio keep playing when you switch between pages. Any component anywhere in the app can call usePlayer() to read or control playback.

Your data and state management layer are in place. Next up, you'll build the home page that displays those albums as a grid of clickable cards.

Build the Home Page with Album Grid

Your app has a layout structure, sample data, and a player context ready to go. The main content area is still empty though.

In this step, you'll build the home page that displays your albums as a grid of clickable cards. Each card links to an album detail page that you'll build in the next step.

In this step, get ready to:
  • Create the home page with a responsive album grid.
  • Build a reusable AlbumCard component with cover art, titles, and hover effects.
  • Link each album card to a dynamic route for the track listing page.
Create the home page

In Next.js App Router, the file app/page.jsx automatically becomes the home page at localhost:3000. Your project may already have a default page.jsx from the scaffold. You'll replace its contents entirely.

  • Open app/page.jsx in the VS Code Explorer sidebar.
  • Replace all existing content with the following code:
import AlbumCard from "@/components/AlbumCard";
import { albums } from "@/lib/data";

export default function Home() {
  return (
    <div>
      <h1 className="mb-6 text-3xl font-bold">Good evening</h1>
      <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
        {albums.map((album) => (
          <AlbumCard key={album.id} album={album} />
        ))}
      </div>
    </div>
  );
}

What does this code do?

  • import { albums } from "@/lib/data" pulls in the array of album objects you created in the previous step.
  • The grid classes create a responsive layout. It starts at 2 columns on small screens and scales up to 5 columns on extra-large screens.
  • albums.map() loops through every album and renders an AlbumCard component for each one, passing the album data as a prop.
  • Save app/page.jsx.

Your browser will show an error because the AlbumCard component doesn't exist yet. You'll fix that in the next substep.

Build the AlbumCard component

Each album card needs to show a cover image, the album title, and the artist name. It also needs to be clickable so the user can navigate to that album's track listing.

  • Create a new file called AlbumCard.jsx inside the components folder.
  • Add the following code to components/AlbumCard.jsx:
import Link from "next/link";
import Image from "next/image";

export default function AlbumCard({ album }) {
  return (
    <Link
      href={`/album/${album.id}`}
      className="group rounded-md bg-zinc-800/50 p-4 transition hover:bg-zinc-700/50"
    >
      {/* Album cover image fills the square container */}
      <div className="relative mb-4 aspect-square overflow-hidden rounded-md shadow-lg">
        <Image
          src={album.cover}
          alt={album.title}
          fill
          className="object-cover"
        />
      </div>
      {/* Album title and artist below the image */}
      <h3 className="truncate font-semibold">{album.title}</h3>
      <p className="mt-1 truncate text-sm text-zinc-400">{album.artist}</p>
    </Link>
  );
}

What does this code do?

  • The entire card is wrapped in a Link component that points to /album/[album.id]. This is a dynamic route. When the learner clicks "Chill Vibes", it navigates to /album/chill-vibes.
  • The Next.js Image component with the fill prop makes the cover art expand to fill its parent container. The aspect-square class keeps the container square.
  • hover:bg-zinc-700/50 lightens the background when the user hovers over a card, giving visual feedback.
  • truncate cuts off long album or artist names with an ellipsis instead of letting them overflow the card.

What are props?

The { album } in the function signature is a prop. Props are how React components receive data from their parent. The home page passes each album object to AlbumCard, and the card uses album.title, album.artist, and album.cover to render the right content.

  • Save components/AlbumCard.jsx.
  • Refresh your browser at localhost:3000. You should see a heading that says "Good evening" with a grid of five album cards below it. Each card shows a cover image, the album title, and the artist name.
  • Hover over any card. The background should lighten slightly.
  • Click any album card. The URL changes to /album/chill-vibes (or whichever album you clicked), but you'll see a 404 page. That's expected because you haven't built the album page yet.

Not seeing any album cards?

  • Check that your AlbumCard.jsx file is inside the components folder (not app or the project root).
  • Make sure the import in page.jsx uses @/components/AlbumCard with a capital A and capital C.
  • If you see a broken image icon, check that next.config.mjs includes picsum.photos in the remotePatterns array. This was set up in a previous step.

Help me debug why my album cards aren't showing up on the home page.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for both files.

import AlbumCard from "@/components/AlbumCard";
import { albums } from "@/lib/data";

export default function Home() {
  return (
    <div>
      <h1 className="mb-6 text-3xl font-bold">Good evening</h1>
      <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
        {albums.map((album) => (
          <AlbumCard key={album.id} album={album} />
        ))}
      </div>
    </div>
  );
}
import Link from "next/link";
import Image from "next/image";

export default function AlbumCard({ album }) {
  return (
    <Link
      href={`/album/${album.id}`}
      className="group rounded-md bg-zinc-800/50 p-4 transition hover:bg-zinc-700/50"
    >
      <div className="relative mb-4 aspect-square overflow-hidden rounded-md shadow-lg">
        <Image
          src={album.cover}
          alt={album.title}
          fill
          className="object-cover"
        />
      </div>
      <h3 className="truncate font-semibold">{album.title}</h3>
      <p className="mt-1 truncate text-sm text-zinc-400">{album.artist}</p>
    </Link>
  );
}

Your home page is looking like a real music app. Next up, you'll build the album detail page so clicking a card actually shows that album's track listing.

Build the Album Page with Track Listing

Your home page has a grid of clickable album cards. Right now, clicking one navigates to /album/chill-vibes (or whichever album you click), but nothing loads because the page doesn't exist yet.

In this step, you'll create a dynamic route that catches any album ID from the URL and displays that album's details. You'll also build a TrackList component that shows every track and lets users click to play.

In this step, get ready to:
  • Create a dynamic route for individual album pages.
  • Build a TrackList component that displays track numbers, titles, artists, and durations.
  • Wire up click-to-play so selecting a track updates the player context.
Create the dynamic album page route

Next.js uses folder names wrapped in square brackets to create dynamic routes. A folder named [id] tells Next.js to match any value in that URL segment and make it available to your page component.

  • In VS Code's Explorer sidebar, right-click the app folder and select New Folder.
  • Name the folder album.
  • Right-click the new album folder and create another folder inside it named [id] (include the square brackets).
  • Inside app/album/[id]/, create a new file called page.jsx and paste the following code:
"use client";

import { useParams } from "next/navigation";
import { albums } from "@/lib/data";
import TrackList from "@/components/TrackList";
import Image from "next/image";

export default function AlbumPage() {
  // Get the album ID from the URL (e.g. /album/chill-vibes -> "chill-vibes")
  const params = useParams();
  const album = albums.find((a) => a.id === params.id);

  // Show a fallback if the album ID doesn't match any data
  if (!album) {
    return <div className="text-zinc-400">Album not found</div>;
  }

  return (
    <div>
      {/* Album header with cover art and metadata */}
      <div className="mb-8 flex items-end gap-6">
        <div className="relative h-48 w-48 shrink-0 overflow-hidden rounded shadow-lg">
          <Image
            src={album.cover}
            alt={album.title}
            fill
            className="object-cover"
          />
        </div>
        <div>
          <p className="text-sm font-medium uppercase text-zinc-400">Album</p>
          <h1 className="mb-2 text-4xl font-bold">{album.title}</h1>
          <p className="text-zinc-400">
            {album.artist} &middot; {album.tracks.length} songs
          </p>
        </div>
      </div>
      {/* Track listing rendered below the header */}
      <TrackList tracks={album.tracks} />
    </div>
  );
}

What does this code do?

  • "use client" marks this as a client component because useParams() needs access to the browser's URL.
  • useParams() reads the dynamic segment from the URL. If the URL is /album/chill-vibes, then params.id equals "chill-vibes".
  • albums.find() searches the data array for the album whose id matches the URL. If nothing matches, the page shows "Album not found".
  • The header section uses Next.js Image with fill to display the album cover at 192x192 pixels.

What is a dynamic route?

In the Next.js App Router, folders inside app/ become URL paths. A folder named [id] acts as a wildcard that matches any value.

So app/album/[id]/page.jsx handles /album/chill-vibes, /album/electric-dreams, and any other album ID you navigate to.

  • Save app/album/[id]/page.jsx.

You'll see an error in your editor because TrackList doesn't exist yet. You'll create it next.

Seeing a different error?

  • Check that your folder structure is exactly app/album/[id]/page.jsx. The square brackets must be part of the folder name.
  • Make sure the file is named page.jsx (not Page.jsx or index.jsx). Next.js uses the exact filename page to identify route pages.

Still stuck? Help me fix my album page route.

Build the track list with click-to-play

The album page needs a component that lists every track and lets users click a row to start playback. This component will talk directly to your PlayerContext so that clicking any track updates the global player state.

  • In VS Code's Explorer sidebar, right-click the components folder and select New File.
  • Name it TrackList.jsx and paste the following code:
"use client";

import { usePlayer } from "@/context/PlayerContext";
import { Play } from "lucide-react";

export default function TrackList({ tracks }) {
  // Pull playTrack and currentTrack from the global player context
  const { playTrack, currentTrack } = usePlayer();

  return (
    <div className="space-y-1">
      {/* Column headers */}
      <div className="grid grid-cols-[16px_1fr_80px] gap-4 border-b border-zinc-700 px-4 pb-2 text-sm text-zinc-400">
        <span>#</span>
        <span>Title</span>
        <span className="text-right">Duration</span>
      </div>
      {/* One clickable row per track */}
      {tracks.map((track, index) => {
        const isActive = currentTrack?.id === track.id;
        return (
          <button
            key={track.id}
            onClick={() => playTrack(track, tracks)}
            className={`grid w-full grid-cols-[16px_1fr_80px] gap-4 rounded px-4 py-2 text-left hover:bg-zinc-800 ${
              isActive ? "text-green-500" : "text-white"
            }`}
          >
            {/* Track number or play icon if active */}
            <span className="flex items-center text-sm text-zinc-400">
              {isActive ? (
                <Play size={14} className="fill-green-500 text-green-500" />
              ) : (
                index + 1
              )}
            </span>
            {/* Track title and artist */}
            <div>
              <p className={`text-sm font-medium ${isActive ? "text-green-500" : ""}`}>
                {track.title}
              </p>
              <p className="text-xs text-zinc-400">{track.artist}</p>
            </div>
            {/* Duration */}
            <span className="text-right text-sm text-zinc-400">
              {track.duration}
            </span>
          </button>
        );
      })}
    </div>
  );
}

What does this code do?

  • usePlayer() pulls playTrack and currentTrack from the PlayerContext you built in a previous step.
  • Each track row is a <button>. Clicking it calls playTrack(track, tracks) which sets the clicked track as the current track and loads the entire album into the queue.
  • isActive checks if the currently playing track matches this row. If it does, the row turns green and the track number is replaced by a Play icon.
  • The CSS grid with grid-cols-[16px_1fr_80px] creates three columns: a narrow number column, a flexible title column, and a fixed-width duration column.

How does click-to-play work across components?

When a user clicks a track, playTrack() updates the PlayerContext state. Because React Context is shared across the entire app, the Player component at the bottom of the screen can read the new track instantly.

You'll wire up the actual audio playback in the Player component in the next step. For now, clicking a track updates the state and highlights the active row in green.

  • Save components/TrackList.jsx.
  • In your browser, go to http://localhost:3000 and click any album card.
  • You should see the album cover image, title, artist name, track count, and a numbered list of tracks below.
  • Click any track row. The row should turn green and show a small Play icon where the track number was.

Page not loading or tracks not showing?

  • If you see a 404 page, double-check that the folder is named [id] with square brackets. Without them, Next.js treats it as a literal path segment.
  • If you see "Album not found", check that the id values in your lib/data.js match the href links in your AlbumCard component.
  • If clicking a track does nothing, confirm that "use client" is the first line in both TrackList.jsx and page.jsx. Hooks only work in client components.

Help me debug my album page and track list.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete app/album/[id]/page.jsx:

"use client";

import { useParams } from "next/navigation";
import { albums } from "@/lib/data";
import TrackList from "@/components/TrackList";
import Image from "next/image";

export default function AlbumPage() {
  const params = useParams();
  const album = albums.find((a) => a.id === params.id);

  if (!album) {
    return <div className="text-zinc-400">Album not found</div>;
  }

  return (
    <div>
      <div className="mb-8 flex items-end gap-6">
        <div className="relative h-48 w-48 shrink-0 overflow-hidden rounded shadow-lg">
          <Image
            src={album.cover}
            alt={album.title}
            fill
            className="object-cover"
          />
        </div>
        <div>
          <p className="text-sm font-medium uppercase text-zinc-400">Album</p>
          <h1 className="mb-2 text-4xl font-bold">{album.title}</h1>
          <p className="text-zinc-400">
            {album.artist} &middot; {album.tracks.length} songs
          </p>
        </div>
      </div>
      <TrackList tracks={album.tracks} />
    </div>
  );
}

And here is the complete components/TrackList.jsx:

"use client";

import { usePlayer } from "@/context/PlayerContext";
import { Play } from "lucide-react";

export default function TrackList({ tracks }) {
  const { playTrack, currentTrack } = usePlayer();

  return (
    <div className="space-y-1">
      <div className="grid grid-cols-[16px_1fr_80px] gap-4 border-b border-zinc-700 px-4 pb-2 text-sm text-zinc-400">
        <span>#</span>
        <span>Title</span>
        <span className="text-right">Duration</span>
      </div>
      {tracks.map((track, index) => {
        const isActive = currentTrack?.id === track.id;
        return (
          <button
            key={track.id}
            onClick={() => playTrack(track, tracks)}
            className={`grid w-full grid-cols-[16px_1fr_80px] gap-4 rounded px-4 py-2 text-left hover:bg-zinc-800 ${
              isActive ? "text-green-500" : "text-white"
            }`}
          >
            <span className="flex items-center text-sm text-zinc-400">
              {isActive ? (
                <Play size={14} className="fill-green-500 text-green-500" />
              ) : (
                index + 1
              )}
            </span>
            <div>
              <p className={`text-sm font-medium ${isActive ? "text-green-500" : ""}`}>
                {track.title}
              </p>
              <p className="text-xs text-zinc-400">{track.artist}</p>
            </div>
            <span className="text-right text-sm text-zinc-400">
              {track.duration}
            </span>
          </button>
        );
      })}
    </div>
  );
}

Your album pages are live and tracks respond to clicks. Next up, you'll connect the Player component to actually play audio when a track is selected.

Build the Functional Audio Player

In the previous step, you wired up the TrackList so clicking a track updates the PlayerContext with the selected track. The track name and play state are stored globally, but nothing actually plays yet.

The Player component at the bottom of your screen is still a placeholder. In this step, you'll replace it with a fully functional audio player that controls the browser's native Audio element using React refs.

In this step, get ready to:
  • Connect the Player component to PlayerContext and the HTML5 Audio element.
  • Wire up play/pause, next, and previous controls.
  • Add a progress bar and volume slider controlled via the Audio API.
Connect the Player to PlayerContext and the Audio API

React manages your UI through state and re-renders. But the browser's Audio element needs to be controlled imperatively. You call .play() and .pause() directly on the DOM element.

The useRef hook gives you a persistent reference to that DOM element. Then useEffect watches for state changes and calls the imperative methods in response.

  • Open components/Player.jsx in VS Code.
  • Select all the existing content and replace it with the following code:
"use client";

import { usePlayer } from "@/context/PlayerContext";
import { useRef, useEffect, useState } from "react";
import { Play, Pause, SkipBack, SkipForward, Volume2 } from "lucide-react";
import Image from "next/image";

export default function Player() {
  const {
    currentTrack,
    isPlaying,
    togglePlay,
    nextTrack,
    prevTrack,
    volume,
    setVolume,
  } = usePlayer();

  const audioRef = useRef(null);
  const [progress, setProgress] = useState(0);
  const [currentTime, setCurrentTime] = useState("0:00");
  const [totalDuration, setTotalDuration] = useState("0:00");

  useEffect(() => {
    if (!audioRef.current) return;
    if (isPlaying) {
      audioRef.current.play();
    } else {
      audioRef.current.pause();
    }
  }, [isPlaying, currentTrack]);

  useEffect(() => {
    if (audioRef.current) {
      audioRef.current.volume = volume;
    }
  }, [volume]);

  function handleTimeUpdate() {
    if (!audioRef.current) return;
    const current = audioRef.current.currentTime;
    const duration = audioRef.current.duration;
    if (duration) {
      setProgress((current / duration) * 100);
      setCurrentTime(formatTime(current));
      setTotalDuration(formatTime(duration));
    }
  }

  function handleSeek(e) {
    if (!audioRef.current) return;
    const duration = audioRef.current.duration;
    audioRef.current.currentTime = (Number(e.target.value) / 100) * duration;
  }

  function handleEnded() {
    nextTrack();
  }

  function formatTime(seconds) {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins}:${secs.toString().padStart(2, "0")}`;
  }

  return (
    <div className="flex h-20 items-center border-t border-zinc-800 bg-black px-4">
      {/* Track info */}
      <div className="flex w-1/4 items-center gap-3">
        {currentTrack && (
          <>
            <div className="relative h-12 w-12 overflow-hidden rounded">
              <Image
                src={currentTrack.cover}
                alt={currentTrack.title}
                fill
                className="object-cover"
              />
            </div>
            <div>
              <p className="text-sm font-medium">{currentTrack.title}</p>
              <p className="text-xs text-zinc-400">{currentTrack.artist}</p>
            </div>
          </>
        )}
      </div>

      {/* Controls */}
      <div className="flex flex-1 flex-col items-center gap-1">
        <div className="flex items-center gap-4">
          <button onClick={prevTrack} className="text-zinc-400 hover:text-white">
            <SkipBack size={20} />
          </button>
          <button
            onClick={togglePlay}
            className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-black"
          >
            {isPlaying ? <Pause size={16} /> : <Play size={16} className="ml-0.5" />}
          </button>
          <button onClick={nextTrack} className="text-zinc-400 hover:text-white">
            <SkipForward size={20} />
          </button>
        </div>
        <div className="flex w-full max-w-md items-center gap-2">
          <span className="text-xs text-zinc-400">{currentTime}</span>
          <input
            type="range"
            min="0"
            max="100"
            value={progress}
            onChange={handleSeek}
            className="flex-1"
          />
          <span className="text-xs text-zinc-400">{totalDuration}</span>
        </div>
      </div>

      {/* Volume */}
      <div className="flex w-1/4 items-center justify-end gap-2">
        <Volume2 size={20} className="text-zinc-400" />
        <input
          type="range"
          min="0"
          max="100"
          value={volume * 100}
          onChange={(e) => setVolume(Number(e.target.value) / 100)}
          className="w-24"
        />
      </div>

      {currentTrack && (
        <audio
          ref={audioRef}
          src={currentTrack.src}
          onTimeUpdate={handleTimeUpdate}
          onEnded={handleEnded}
        />
      )}
    </div>
  );
}

What does this code do?

This component has three major sections working together:

  • usePlayer() pulls the global playback state from PlayerContext. This gives the Player access to the current track, play/pause state, queue navigation, and volume.
  • audioRef is a React ref that points to the hidden <audio> element at the bottom of the JSX. The two useEffect hooks watch for changes in isPlaying and volume, then call .play(), .pause(), or set .volume on the actual audio element.
  • handleTimeUpdate() fires every time the audio element's playback position changes. It calculates the percentage progress and formats the timestamps for display.
  • handleEnded() automatically advances to the next track when the current one finishes.

Why use a ref instead of state for the audio element?

React state triggers re-renders. If you stored the audio element in state, every time you called .play() or updated currentTime, the component would re-render and recreate the audio element. Refs persist across renders without triggering them.

This pattern (state for UI display, refs for imperative DOM control) is common whenever React components interact with browser APIs like audio, video, or canvas.

  • Save components/Player.jsx.
  • In your browser, navigate to any album page and click on a track.
  • You should see the bottom player bar update with the track's cover art, title, and artist name. The play/pause button should appear as a white circle with controls.

Player bar not updating when you click a track?

  • Make sure the import path is @/context/PlayerContext. A typo in the path means the hook returns nothing and the component won't respond to track clicks.
  • Check that you kept "use client" as the very first line. Without it, Next.js treats the file as a server component and hooks like useRef and useEffect will throw an error.
  • If you see a blank white bar, confirm that your PlayerProvider is still wrapping the app in app/layout.jsx.

Still stuck? Help me debug why my Player component isn't showing the current track info when I click a track on the album page.

Understand the playback controls

The center section of the Player renders three buttons that map directly to functions from your PlayerContext.

How do the controls work?

  • The SkipBack button calls prevTrack(). This moves backward through the queue array, wrapping to the last track if you're at the beginning.
  • The center circle button calls togglePlay(). It renders either a Pause or Play icon depending on the isPlaying state.
  • The SkipForward button calls nextTrack(). This advances through the queue, wrapping back to the first track at the end.

When any of these functions update currentTrack or isPlaying in context, the useEffect in Player fires and calls .play() or .pause() on the audio element automatically.

  • Click the play/pause button in the player bar. The icon should toggle between a play triangle and a pause symbol.
  • Click the skip forward button. The track info in the left corner should update to show the next track in the album.
  • Click the skip back button. The track info should return to the previous track.
Test the progress bar and volume slider

The bottom of the controls section shows a progress bar. The right side of the player shows a volume slider. Both use HTML <input type="range"> elements connected to the audio element's properties.

How do the sliders control audio?

The progress bar displays a value from 0 to 100 calculated as a percentage of currentTime / duration. When you drag it, handleSeek() converts the slider value back into seconds and sets audioRef.current.currentTime directly.

The volume slider displays volume * 100 (since the Audio API uses 0 to 1, but sliders work better with 0 to 100). Dragging it calls setVolume() which updates context, triggering the volume useEffect to sync the new value to the audio element.

  • With a track playing, drag the progress bar left or right. The audio should jump to that position in the track.
  • Drag the volume slider on the right side of the player. The audio volume should change as you move it.
  • Navigate to the home page by clicking Home in the sidebar. The audio should keep playing as the page changes.

Why does audio persist across page navigations?

The Player component lives in app/layout.jsx. In the Next.js App Router, layouts never unmount when you navigate between pages. Only the {children} area inside the layout re-renders.

This means the <audio> element stays mounted in the DOM the entire time. It never gets destroyed and recreated, so playback continues uninterrupted. This is the same architectural pattern real Spotify uses.

Audio not playing or progress bar stuck at 0:00?

  • Your browser needs audio files to play. The data file references paths like /audio/sample1.mp3. Create a public/audio folder and place any MP3 files there named sample1.mp3, sample2.mp3, and sample3.mp3. Without these files, the controls will work visually but no sound will play.
  • If the browser blocks autoplay, click the play button manually after selecting a track. Most browsers require a user interaction before allowing audio to start.
  • Check the browser console for errors. A 404 on the audio source means the file path or filename doesn't match what's in lib/data.js.

Need more help? Help me debug why my audio player controls appear but no sound plays when I click tracks.

✔️ Awesome, I've got everything!

Great. Double check you've saved components/Player.jsx and that all your controls are responding.

ⓧ I'd like to double check the full code

Here is the complete components/Player.jsx file:

"use client";

import { usePlayer } from "@/context/PlayerContext";
import { useRef, useEffect, useState } from "react";
import { Play, Pause, SkipBack, SkipForward, Volume2 } from "lucide-react";
import Image from "next/image";

export default function Player() {
  const {
    currentTrack,
    isPlaying,
    togglePlay,
    nextTrack,
    prevTrack,
    volume,
    setVolume,
  } = usePlayer();

  const audioRef = useRef(null);
  const [progress, setProgress] = useState(0);
  const [currentTime, setCurrentTime] = useState("0:00");
  const [totalDuration, setTotalDuration] = useState("0:00");

  useEffect(() => {
    if (!audioRef.current) return;
    if (isPlaying) {
      audioRef.current.play();
    } else {
      audioRef.current.pause();
    }
  }, [isPlaying, currentTrack]);

  useEffect(() => {
    if (audioRef.current) {
      audioRef.current.volume = volume;
    }
  }, [volume]);

  function handleTimeUpdate() {
    if (!audioRef.current) return;
    const current = audioRef.current.currentTime;
    const duration = audioRef.current.duration;
    if (duration) {
      setProgress((current / duration) * 100);
      setCurrentTime(formatTime(current));
      setTotalDuration(formatTime(duration));
    }
  }

  function handleSeek(e) {
    if (!audioRef.current) return;
    const duration = audioRef.current.duration;
    audioRef.current.currentTime = (Number(e.target.value) / 100) * duration;
  }

  function handleEnded() {
    nextTrack();
  }

  function formatTime(seconds) {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins}:${secs.toString().padStart(2, "0")}`;
  }

  return (
    <div className="flex h-20 items-center border-t border-zinc-800 bg-black px-4">
      {/* Track info */}
      <div className="flex w-1/4 items-center gap-3">
        {currentTrack && (
          <>
            <div className="relative h-12 w-12 overflow-hidden rounded">
              <Image
                src={currentTrack.cover}
                alt={currentTrack.title}
                fill
                className="object-cover"
              />
            </div>
            <div>
              <p className="text-sm font-medium">{currentTrack.title}</p>
              <p className="text-xs text-zinc-400">{currentTrack.artist}</p>
            </div>
          </>
        )}
      </div>

      {/* Controls */}
      <div className="flex flex-1 flex-col items-center gap-1">
        <div className="flex items-center gap-4">
          <button onClick={prevTrack} className="text-zinc-400 hover:text-white">
            <SkipBack size={20} />
          </button>
          <button
            onClick={togglePlay}
            className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-black"
          >
            {isPlaying ? <Pause size={16} /> : <Play size={16} className="ml-0.5" />}
          </button>
          <button onClick={nextTrack} className="text-zinc-400 hover:text-white">
            <SkipForward size={20} />
          </button>
        </div>
        <div className="flex w-full max-w-md items-center gap-2">
          <span className="text-xs text-zinc-400">{currentTime}</span>
          <input
            type="range"
            min="0"
            max="100"
            value={progress}
            onChange={handleSeek}
            className="flex-1"
          />
          <span className="text-xs text-zinc-400">{totalDuration}</span>
        </div>
      </div>

      {/* Volume */}
      <div className="flex w-1/4 items-center justify-end gap-2">
        <Volume2 size={20} className="text-zinc-400" />
        <input
          type="range"
          min="0"
          max="100"
          value={volume * 100}
          onChange={(e) => setVolume(Number(e.target.value) / 100)}
          className="w-24"
        />
      </div>

      {currentTrack && (
        <audio
          ref={audioRef}
          src={currentTrack.src}
          onTimeUpdate={handleTimeUpdate}
          onEnded={handleEnded}
        />
      )}
    </div>
  );
}

Your Spotify clone now has a fully functional audio player. Next up, you'll take on a secret mission to add real-time search filtering across your music library.

Secret mission

Add Real-Time Search

Your Spotify clone has albums and tracks, but no way to find them quickly. In this challenge, you'll add a search page that filters through all your music as you type. The results update in real-time, grouped by Albums and Tracks, with clickable entries that play songs or navigate to albums.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files to continue experimenting, or delete them to free up disk space.

Resources you used:

  • Your Next.js project folder (source code, configuration files).
  • The node_modules directory inside your project (installed packages).
  • The local development server process.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with your Spotify clone.

  • Your project folder stays on your Desktop with all source code intact.
  • You can restart the dev server anytime by opening a terminal in your project folder and running npm run dev.
  • Node.js and VS Code remain installed on your machine for future projects.

Pause - I'll come back to this later

Shut down the running development server to free up memory. Your project files stay on disk so you can resume later.

  • Switch to the terminal where your dev server is running.
  • Press Ctrl+C to stop the server.
  • When you are ready to come back, open a terminal in your project folder and run npm run dev to start the server again.

Delete - I don't want to use this again

Remove all project files from your machine. This frees up disk space but cannot be undone.

  • First, stop the dev server if it is still running by pressing Ctrl+C in the terminal.
  • Delete your project folder by running the following command from your Desktop.

macOS

cd ~/Desktop
rm -rf spotify-clone

Windows

cd ~\Desktop
rmdir /s /q spotify-clone

Replace with your folder name

If you named your project something other than spotify-clone during setup, replace the folder name in the command above with whatever you chose.

Nice Work!

Nice Work!

You just built a fully functional Spotify-style music player with Next.js. Your app plays audio, navigates between pages, and keeps the music going the entire time.

Here's what you learned:

  • Built a Next.js 16 App Router application with persistent layouts that keep audio playing seamlessly across page navigations.
  • Created a PlayerContext using React Context for global state management without any external libraries.
  • Built a functional audio player with HTML5 Audio API controls including play/pause, next/previous, a progress bar, and volume slider.
  • Added real-time search filtering across tracks and albums with debounced input and grouped results.

Ready to quiz yourself?