Build a Spotify Web Player with Next.js
Build a full-stack Spotify player with OAuth, search, and live audio.
Introduction
30 Second Summary
Every time you tap play on Spotify, a chain of authentication, API calls, and real-time audio streaming happens behind the scenes. What if you could build that entire experience yourself?
In this project, you will build a custom Spotify web player using Next.js. You'll log in with your Spotify account, search for songs, and stream full tracks directly from your own interface.
What You'll Build
A Next.js web application that authenticates with Spotify via OAuth 2.0, searches the music catalog, and streams full tracks using the Web Playback SDK.
By the end of this project, you'll have:
- A working OAuth 2.0 Authorization Code flow that securely manages Spotify tokens on the server.
- A search interface that queries Spotify's catalog and displays track results with album art.
- A persistent player bar with play/pause, skip, seek, and volume controls powered by real-time SDK events.
- Secret Mission: a personalized "Top Tracks" page that displays your most-played songs and lets you play them directly.
Are there any prerequisites?
You'll need an active Spotify Premium subscription. The Web Playback SDK requires Premium to stream full tracks.
You should also have Node.js installed and some familiarity with Next.js and TypeScript.
Set Up the Next.js Project
Every web app starts with a foundation. Your Spotify player needs a framework that handles both the frontend UI and the backend API routes for authentication and data fetching.
In this step, you'll scaffold a fresh Next.js project, verify it runs locally, then set up the environment variables and themed styling your app needs.
In this step, get ready to:
- Scaffold a Next.js project with TypeScript and Tailwind CSS.
- Verify the dev server runs at http://localhost:3000.
- Create environment variables and Spotify-themed base files.
Scaffold the Next.js app
You'll use create-next-app to generate a fully configured project with TypeScript, Tailwind CSS, and the App Router already wired up.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Next.js project by running:
npx create-next-app@latest
What will this command ask me?
The CLI will ask you two questions:
- "What is your project named?" Type spotify-web-player and press Enter.
- "Would you like to use the recommended Next.js defaults?" Select Yes, use recommended defaults and press Enter. This gives you TypeScript, ESLint, Tailwind CSS, and the App Router.
The CLI will install all dependencies automatically. This may take a minute.
- Move into the project folder and start the dev server by running:
cd spotify-web-player
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.
Don't see the welcome page?
- Check your terminal for error messages. If the port is already in use, stop whatever is running on port 3000 and try again.
- Make sure you ran npm run dev from inside the spotify-web-player folder.
Still stuck? Help me debug why my Next.js dev server isn't starting.
Set up environment variables
Your app will need Spotify API credentials to authenticate users. You'll store these in a .env.local file that Next.js automatically loads.
- Stop the dev server by pressing Ctrl+C in your terminal.
- Create a new file called .env.local in the root of your spotify-web-player folder by running:
touch .env.local
- Open .env.local in VS Code and paste the following placeholder values:
SPOTIFY_CLIENT_ID=your-client-id-here
SPOTIFY_CLIENT_SECRET=your-client-secret-here
SPOTIFY_REDIRECT_URI=http://127.0.0.1:3000/api/auth/callback
What does this file do?
- SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET are credentials you'll get from Spotify's Developer Dashboard in the next step.
- SPOTIFY_REDIRECT_URI tells Spotify where to send the user after they log in. It uses 127.0.0.1 instead of localhost because Spotify requires this format for local development.
- Save .env.local.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
SPOTIFY_CLIENT_ID=your-client-id-here
SPOTIFY_CLIENT_SECRET=your-client-secret-here
SPOTIFY_REDIRECT_URI=http://127.0.0.1:3000/api/auth/callback
Add Spotify-themed styles and the home page
The scaffolded project comes with default Next.js styling. You'll replace it with a dark, Spotify-inspired theme and a login page that greets unauthenticated users.
- Open app/globals.css in VS Code. Select all the existing content and replace it with:
@import "tailwindcss";
:root {
--spotify-green: #1db954;
--spotify-black: #191414;
--spotify-dark-gray: #282828;
--spotify-light-gray: #b3b3b3;
}
body {
background-color: var(--spotify-black);
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: #4d4d4d;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
}
What does this code do?
- @import "tailwindcss" loads Tailwind CSS 4's utility classes. This replaces the older @tailwind directives from previous versions.
- The :root variables define Spotify's brand colors as reusable CSS custom properties.
- The input[type="range"] styles create custom slider controls you'll use later for the seek bar and volume.
- Save app/globals.css.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
@import "tailwindcss";
:root {
--spotify-green: #1db954;
--spotify-black: #191414;
--spotify-dark-gray: #282828;
--spotify-light-gray: #b3b3b3;
}
body {
background-color: var(--spotify-black);
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: #4d4d4d;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
}
- Open app/layout.tsx. Select all the existing content and replace it with:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Spotify Web Player",
description: "A custom Spotify web player built with Next.js",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} bg-black text-white min-h-screen`}>
{children}
</body>
</html>
);
}
What does this code do?
- It imports the Inter font from Google Fonts and applies it to the entire app.
- The metadata export sets the browser tab title and description for your player.
- The body element gets a black background with white text, matching Spotify's dark theme.
- Save app/layout.tsx.
- Start the dev server again by running:
npm run dev
- Refresh your browser at http://localhost:3000. You should see a fully black page. The default Next.js content is gone, replaced by your dark Spotify theme.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Spotify Web Player",
description: "A custom Spotify web player built with Next.js",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} bg-black text-white min-h-screen`}>
{children}
</body>
</html>
);
}
- Open app/page.tsx. Select all the existing content and replace it with:
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import SearchPage from "@/components/SearchPage";
import PlayerProvider from "@/components/PlayerProvider";
export default async function Home() {
const cookieStore = await cookies();
const accessToken = cookieStore.get("spotify_access_token")?.value;
if (!accessToken) {
return (
<main className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Spotify Web Player</h1>
<p className="text-gray-400 mb-8">
Sign in with your Spotify Premium account to start playing music.
</p>
<a
href="/api/auth/login"
className="bg-[#1db954] text-black font-semibold px-8 py-3 rounded-full hover:bg-[#1ed760] transition-colors"
>
Log in with Spotify
</a>
</div>
</main>
);
}
return (
<PlayerProvider accessToken={accessToken}>
<SearchPage accessToken={accessToken} />
</PlayerProvider>
);
}
What does this code do?
- It checks for a spotify_access_token cookie. If the user isn't logged in, it shows a centered login screen with a green "Log in with Spotify" button.
- If the user IS logged in, it renders the SearchPage wrapped in a PlayerProvider component. You'll build both of these in later steps.
- Save app/page.tsx.
Seeing a "Module not found" error?
This is expected. Your browser and terminal will show an error about @/components/SearchPage or @/components/PlayerProvider not being found.
These components don't exist yet. You'll create them in Steps 4 and 5. For now, the file is saved and ready.
✔️ Awesome, I've got everything!
Great. The error is expected and will resolve once you build the missing components in later steps.
ⓧ I'd like to double check the full code
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import SearchPage from "@/components/SearchPage";
import PlayerProvider from "@/components/PlayerProvider";
export default async function Home() {
const cookieStore = await cookies();
const accessToken = cookieStore.get("spotify_access_token")?.value;
if (!accessToken) {
return (
<main className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Spotify Web Player</h1>
<p className="text-gray-400 mb-8">
Sign in with your Spotify Premium account to start playing music.
</p>
<a
href="/api/auth/login"
className="bg-[#1db954] text-black font-semibold px-8 py-3 rounded-full hover:bg-[#1ed760] transition-colors"
>
Log in with Spotify
</a>
</div>
</main>
);
}
return (
<PlayerProvider accessToken={accessToken}>
<SearchPage accessToken={accessToken} />
</PlayerProvider>
);
}
Your project is scaffolded and themed. Next up, you'll register a Spotify Developer App to get the credentials that power your login flow.
Register a Spotify Developer App
Your Next.js app is scaffolded and ready to go. The next piece of the puzzle is connecting it to Spotify's servers so it can authenticate users and stream music.
Every app that uses the Spotify Web API must be registered in the Spotify Developer Dashboard. Registration gives you a Client ID and Client Secret that your app uses to prove its identity during the OAuth login flow.
In this step, get ready to:
- Create a new app in the Spotify Developer Dashboard.
- Configure the redirect URI for local development.
- Copy your app credentials into the project.
Create a new app in the Spotify Developer Dashboard
Spotify needs to know about your app before it can grant access to its API. You register your app in the Developer Dashboard to get your unique credentials.
- Open your browser and navigate to https://developer.spotify.com/dashboard.
- Log in with your Spotify Premium account.
- If prompted, read and accept the Developer Terms of Service.
- Click the Create app button.
- Enter a name for your app in the App Name field (e.g. NextWork Spotify Player).
- Enter a short description in the App Description field (e.g. A custom web player built with Next.js).
- In the Redirect URI field, enter http://127.0.0.1:3000/api/auth/callback.
- Under the APIs section, check both Web API and Web Playback SDK.
- Check the Developer Terms of Service checkbox.
- Click Save.
Why both Web API and Web Playback SDK?
The Web API lets your app search Spotify's catalog and control playback. The Web Playback SDK turns your browser into an actual Spotify Connect device that streams full audio.
You need both selected so Spotify grants the right permissions for searching tracks and playing them directly in the browser.
Why 127.0.0.1 instead of localhost?
Spotify requires HTTPS for all redirect URIs except loopback IP addresses. The literal 127.0.0.1 is the loopback exception that allows plain HTTP during local development. Using localhost is not allowed.
Your app is now registered. You should see your new app listed on the Dashboard.
Copy your Client ID and Client Secret into .env.local
Your registered app has a Client ID (public identifier) and a Client Secret (private key). Your Next.js app needs both to exchange authorization codes for access tokens.
- In the Spotify Developer Dashboard, click on the name of the app you just created.
- Click the Settings button.
- You should see your Client ID displayed on this page. Copy it.
- Click View client secret to reveal your Client Secret. Copy it.
Keep your Client Secret private
Your Client Secret is like a password. Never commit it to version control or share it publicly. The .env.local file is already in .gitignore by default in Next.js projects, so your credentials stay safe.
- Switch back to VS Code.
- Open the .env.local file.
- Find this line:
SPOTIFY_CLIENT_ID=your-client-id-here
- Replace your-client-id-here with the Client ID you copied from the Dashboard.
- Find this line:
SPOTIFY_CLIENT_SECRET=your-client-secret-here
- Replace your-client-secret-here with the Client Secret you copied from the Dashboard.
- Leave the SPOTIFY_REDIRECT_URI line unchanged. It already has the correct value.
- Save .env.local.
Your .env.local should now look like this (with your real values):
SPOTIFY_CLIENT_ID=your-actual-client-id
SPOTIFY_CLIENT_SECRET=your-actual-client-secret
SPOTIFY_REDIRECT_URI=http://127.0.0.1:3000/api/auth/callback
Not sure where to find your credentials?
Make sure you clicked into your app (by clicking its name) and then clicked Settings. The Client ID is displayed directly. The Client Secret is hidden behind the View client secret link.
If you do not see your app listed, make sure you are signed in with the same Spotify account you used to create it.
Help me find my Spotify app credentials.
Your Spotify Developer App is registered and your project has real credentials. Next up, you'll use these credentials to build the OAuth login flow that lets users sign in with their Spotify account.
Build the OAuth Login Flow
Your Spotify Developer App is registered and your credentials are stored safely in .env.local. Now you need to build the authentication bridge between your Next.js app and Spotify's servers.
OAuth 2.0 Authorization Code flow works in two stages. First, your app redirects the user to Spotify to grant permission. Then, Spotify redirects back to your app with a temporary code that you exchange for long-lived access tokens.
In this step, get ready to:
- Create a login Route Handler that redirects users to Spotify's authorization page.
- Create a callback Route Handler that exchanges the authorization code for tokens.
- Store tokens securely in HTTP-only cookies.
Create the login Route Handler
The homepage already has a "Log in with Spotify" link pointing to /api/auth/login. You need to create the Route Handler at that path so it redirects the user to Spotify's authorization page with the correct permissions.
What are scopes?
Scopes tell Spotify exactly what your app wants to do. For example, the streaming scope lets you play music, while user-modify-playback-state lets you control playback. You request all needed scopes upfront so the user only has to authorize once.
- In VS Code's Explorer sidebar, right-click the app folder and select New File.
- Type api/auth/login/route.ts as the file name and press Enter. VS Code creates the nested folders automatically.
- Paste the following code into app/api/auth/login/route.ts:
import { NextResponse } from "next/server";
export async function GET() {
const clientId = process.env.SPOTIFY_CLIENT_ID!;
const redirectUri = process.env.SPOTIFY_REDIRECT_URI!;
const scopes = [
"streaming",
"user-read-email",
"user-read-private",
"user-modify-playback-state",
"user-read-playback-state",
].join(" ");
const state = crypto.randomUUID();
const params = new URLSearchParams({
response_type: "code",
client_id: clientId,
scope: scopes,
redirect_uri: redirectUri,
state: state,
});
return NextResponse.redirect(
`https://accounts.spotify.com/authorize?${params.toString()}`
);
}
What does this code do?
- Reads your Client ID and redirect URI from environment variables.
- Defines five scopes that cover streaming, reading user info, and controlling playback.
- Generates a random state value to prevent cross-site request forgery attacks.
- Builds a URL pointing to Spotify's /authorize endpoint and redirects the user there.
- Save app/api/auth/login/route.ts.
- In your browser, navigate directly to http://localhost:3000/api/auth/login. You should be redirected to Spotify's authorization page asking you to grant permissions.
Not redirecting to Spotify?
- Check that your .env.local file has the correct SPOTIFY_CLIENT_ID value from your Spotify Developer Dashboard.
- Make sure your dev server is still running. If it stopped, restart it with npm run dev.
- Confirm you're visiting http://localhost:3000/api/auth/login (not just /api/auth/login without the domain).
Why isn't my login route redirecting to Spotify?
✔️ 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
import { NextResponse } from "next/server";
export async function GET() {
const clientId = process.env.SPOTIFY_CLIENT_ID!;
const redirectUri = process.env.SPOTIFY_REDIRECT_URI!;
const scopes = [
"streaming",
"user-read-email",
"user-read-private",
"user-modify-playback-state",
"user-read-playback-state",
].join(" ");
const state = crypto.randomUUID();
const params = new URLSearchParams({
response_type: "code",
client_id: clientId,
scope: scopes,
redirect_uri: redirectUri,
state: state,
});
return NextResponse.redirect(
`https://accounts.spotify.com/authorize?${params.toString()}`
);
}
Create the callback Route Handler
After the user clicks "Agree" on Spotify's authorization page, Spotify redirects them back to your callback URL with an authorization code. This handler catches that redirect, exchanges the code for access and refresh tokens, and stores them securely.
Why HTTP-only cookies?
HTTP-only cookies cannot be read by JavaScript running in the browser. This protects your Spotify tokens from cross-site scripting (XSS) attacks. Your server-side Route Handlers can still read them to make authenticated API calls.
- In VS Code's Explorer sidebar, right-click the app/api/auth folder and select New File.
- Type callback/route.ts as the file name and press Enter.
- Paste the following code into app/api/auth/callback/route.ts:
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get("code");
const error = searchParams.get("error");
if (error || !code) {
return NextResponse.redirect(new URL("/", request.url));
}
const clientId = process.env.SPOTIFY_CLIENT_ID!;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET!;
const redirectUri = process.env.SPOTIFY_REDIRECT_URI!;
const tokenResponse = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${clientId}:${clientSecret}`).toString("base64"),
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: redirectUri,
}),
});
if (!tokenResponse.ok) {
return NextResponse.redirect(new URL("/", request.url));
}
const tokenData = await tokenResponse.json();
const response = NextResponse.redirect(new URL("/", request.url));
response.cookies.set("spotify_access_token", tokenData.access_token, {
httpOnly: true,
secure: false,
maxAge: tokenData.expires_in,
path: "/",
});
response.cookies.set("spotify_refresh_token", tokenData.refresh_token, {
httpOnly: true,
secure: false,
maxAge: 60 * 60 * 24 * 30,
path: "/",
});
return response;
}
What does this code do?
- Extracts the code parameter from the callback URL that Spotify sends back.
- If the user denied access or something went wrong, redirects them back to the homepage.
- Sends a POST request to Spotify's https://accounts.spotify.com/api/token endpoint with your credentials encoded in Base64.
- Stores the access_token (expires in ~60 minutes) and refresh_token (lasts 30 days) as HTTP-only cookies.
- Redirects the user back to the homepage, where the app detects the cookie and shows the logged-in view.
- Save app/api/auth/callback/route.ts.
- Navigate to http://localhost:3000/api/auth/login again in your browser.
- Click Agree on Spotify's authorization page to grant permissions.
- You'll be redirected back to your app. Open DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Application tab, expand Cookies in the left sidebar, and select your localhost domain.
- You should see spotify_access_token and spotify_refresh_token listed in the cookies table.
Seeing an error page after login?
Your homepage tries to render components (SearchPage and PlayerProvider) that you haven't built yet. The important thing is that your cookies are set in the Application tab. You'll build those components in the next steps.
Cookies not showing up?
- Verify that the redirect URI in your Spotify Developer Dashboard is exactly http://127.0.0.1:3000/api/auth/callback. Even a trailing slash will cause a mismatch.
- Check that SPOTIFY_CLIENT_SECRET in .env.local matches the secret shown in your Spotify app's settings.
- If you see a "INVALID_CLIENT" error in the network tab, your Client ID or Client Secret is incorrect.
Why aren't my Spotify cookies appearing after login?
✔️ 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
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get("code");
const error = searchParams.get("error");
if (error || !code) {
return NextResponse.redirect(new URL("/", request.url));
}
const clientId = process.env.SPOTIFY_CLIENT_ID!;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET!;
const redirectUri = process.env.SPOTIFY_REDIRECT_URI!;
const tokenResponse = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${clientId}:${clientSecret}`).toString("base64"),
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: redirectUri,
}),
});
if (!tokenResponse.ok) {
return NextResponse.redirect(new URL("/", request.url));
}
const tokenData = await tokenResponse.json();
const response = NextResponse.redirect(new URL("/", request.url));
response.cookies.set("spotify_access_token", tokenData.access_token, {
httpOnly: true,
secure: false,
maxAge: tokenData.expires_in,
path: "/",
});
response.cookies.set("spotify_refresh_token", tokenData.refresh_token, {
httpOnly: true,
secure: false,
maxAge: 60 * 60 * 24 * 30,
path: "/",
});
return response;
}
Your authentication flow is complete. When a user clicks "Log in with Spotify," they're sent to Spotify's authorization page, and after granting permission, they return to your app with valid tokens stored in cookies. Next up, you'll build the search interface that uses these tokens to query Spotify's music catalog.
Build the Search Interface
Your OAuth flow is working. You can log in, exchange an authorization code for tokens, and store them securely in cookies. Now it's time to build the feature that makes those tokens useful.
In this step, you'll create a search interface with two parts. A server-side Route Handler securely proxies search requests to the Spotify Web API, keeping your access token hidden from the browser. A client component renders the search form and displays track results with album art.
In this step, get ready to:
- Create a Route Handler that proxies search queries to Spotify's catalog.
- Build a client component with a search input and results grid.
- Display track results as cards showing album art, track name, and artist name.
Create the search Route Handler
Your browser should never send your access token directly to Spotify. Instead, the client calls your own API route, which reads the token from the HTTP-only cookie and forwards the request server-side.
- Create a new folder app/api/search/ inside your project.
- Create a file called route.ts inside that folder and add the following code:
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(request: NextRequest) {
const cookieStore = await cookies();
const accessToken = cookieStore.get("spotify_access_token")?.value;
if (!accessToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const query = request.nextUrl.searchParams.get("q");
if (!query) {
return NextResponse.json({ error: "Query required" }, { status: 400 });
}
const params = new URLSearchParams({
q: query,
type: "track",
limit: "10",
});
const response = await fetch(
`https://api.spotify.com/v1/search?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.ok) {
return NextResponse.json(
{ error: "Failed to search" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
}
What does this code do?
This Route Handler acts as a secure proxy between your browser and Spotify's API.
- It reads the spotify_access_token from the HTTP-only cookie. If the token is missing, it returns a 401 error.
- It grabs the search query from the q URL parameter that the client sends.
- It calls Spotify's GET /v1/search endpoint with type=track and limit=10 to fetch up to 10 track results.
- It forwards the JSON response back to the client without exposing the access token.
- Save app/api/search/route.ts.
You won't see a change in the browser yet. The search page you build next will call this endpoint.
Why a proxy instead of calling Spotify directly?
Your access token is stored in an HTTP-only cookie, which means JavaScript in the browser cannot read it. The Route Handler runs server-side where it can access the cookie, attach the token to the request, and keep it invisible to client code.
This pattern also lets you add rate limiting, caching, or error handling in one place without touching the frontend.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(request: NextRequest) {
const cookieStore = await cookies();
const accessToken = cookieStore.get("spotify_access_token")?.value;
if (!accessToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const query = request.nextUrl.searchParams.get("q");
if (!query) {
return NextResponse.json({ error: "Query required" }, { status: 400 });
}
const params = new URLSearchParams({
q: query,
type: "track",
limit: "10",
});
const response = await fetch(
`https://api.spotify.com/v1/search?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.ok) {
return NextResponse.json(
{ error: "Failed to search" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
}
Build the search page component
The search page is a client component that handles user input, calls your search route, and renders the results as a track list. Each track shows its album art, name, artist, and duration.
- Create a new file called SearchPage.tsx in your components/ folder by adding the following code:
"use client";
import { useState } from "react";
import { usePlayer } from "./PlayerProvider";
interface SpotifyTrack {
id: string;
name: string;
uri: string;
duration_ms: number;
artists: { name: string }[];
album: {
name: string;
images: { url: string; height: number; width: number }[];
};
}
export default function SearchPage({
accessToken,
}: {
accessToken: string;
}) {
const [query, setQuery] = useState("");
const [tracks, setTracks] = useState<SpotifyTrack[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { playTrack, deviceId } = usePlayer();
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
setIsLoading(true);
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
const data = await response.json();
setTracks(data.tracks?.items || []);
} catch (error) {
console.error("Search failed:", error);
} finally {
setIsLoading(false);
}
};
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
return (
<main className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Search</h1>
<form onSubmit={handleSearch} className="mb-8">
<div className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="What do you want to listen to?"
className="flex-1 bg-[#282828] text-white px-4 py-3 rounded-full outline-none focus:ring-2 focus:ring-white placeholder-gray-400"
/>
<button
type="submit"
disabled={isLoading}
className="bg-[#1db954] text-black font-semibold px-6 py-3 rounded-full hover:bg-[#1ed760] transition-colors disabled:opacity-50"
>
{isLoading ? "Searching..." : "Search"}
</button>
</div>
</form>
{!deviceId && (
<div className="bg-yellow-900/30 border border-yellow-700 text-yellow-200 px-4 py-3 rounded-lg mb-4">
<p className="text-sm">
Connecting to Spotify... Please wait a moment.
</p>
</div>
)}
{tracks.length > 0 && (
<div className="space-y-2">
{tracks.map((track, index) => (
<button
key={track.id}
onClick={() => playTrack(track.uri)}
disabled={!deviceId}
className="w-full flex items-center gap-4 p-3 rounded-lg hover:bg-[#282828] transition-colors text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="text-gray-400 w-6 text-right text-sm">
{index + 1}
</span>
{track.album.images[2] && (
<img
src={track.album.images[2].url}
alt={track.album.name}
className="w-10 h-10 rounded"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{track.name}</p>
<p className="text-gray-400 text-sm truncate">
{track.artists.map((a) => a.name).join(", ")}
</p>
</div>
<span className="text-gray-400 text-sm">
{track.album.name}
</span>
<span className="text-gray-400 text-sm w-12 text-right">
{formatDuration(track.duration_ms)}
</span>
</button>
))}
</div>
)}
</main>
);
}
What does this component do?
This client component manages the full search lifecycle from input to display.
- The SpotifyTrack interface defines the shape of each track object returned by the API (id, name, uri, duration, artists, and album images).
- The handleSearch function calls your /api/search route with the encoded query and stores the resulting tracks in state.
- The usePlayer hook provides playTrack and deviceId from the PlayerProvider. These enable playback once the SDK is connected in the next step.
- Each track renders as a clickable button showing a number, album thumbnail, track name, artist name, album name, and formatted duration.
- Save components/SearchPage.tsx.
- Refresh your browser at http://localhost:3000. After logging in, you should see a search bar with a green Search button.
- Type a song name (e.g. Bohemian Rhapsody) and click Search. You should see a list of tracks with small album art thumbnails, track names, artist names, and durations.
Not seeing search results?
- Make sure you're logged in. If you see the login page instead of the search bar, click Log in with Spotify first.
- Check that app/api/search/route.ts exists at exactly that path. The folder must be app/api/search/ with a route.ts file inside.
- Open your browser's DevTools Network tab and look for the /api/search request. A 401 status means your login session expired. Log out and back in.
Still stuck? Help me debug why my Spotify search isn't returning results.
Why are the track buttons disabled?
You'll notice the track buttons appear slightly faded and show a "Connecting to Spotify..." banner. That's because the deviceId from the Web Playback SDK isn't connected yet. The buttons will become clickable and play music after the next step wires up the SDK.
✔️ 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
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(request: NextRequest) {
const cookieStore = await cookies();
const accessToken = cookieStore.get("spotify_access_token")?.value;
if (!accessToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const query = request.nextUrl.searchParams.get("q");
if (!query) {
return NextResponse.json({ error: "Query required" }, { status: 400 });
}
const params = new URLSearchParams({
q: query,
type: "track",
limit: "10",
});
const response = await fetch(
`https://api.spotify.com/v1/search?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.ok) {
return NextResponse.json(
{ error: "Failed to search" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
}
"use client";
import { useState } from "react";
import { usePlayer } from "./PlayerProvider";
interface SpotifyTrack {
id: string;
name: string;
uri: string;
duration_ms: number;
artists: { name: string }[];
album: {
name: string;
images: { url: string; height: number; width: number }[];
};
}
export default function SearchPage({
accessToken,
}: {
accessToken: string;
}) {
const [query, setQuery] = useState("");
const [tracks, setTracks] = useState<SpotifyTrack[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { playTrack, deviceId } = usePlayer();
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
setIsLoading(true);
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
const data = await response.json();
setTracks(data.tracks?.items || []);
} catch (error) {
console.error("Search failed:", error);
} finally {
setIsLoading(false);
}
};
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
return (
<main className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Search</h1>
<form onSubmit={handleSearch} className="mb-8">
<div className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="What do you want to listen to?"
className="flex-1 bg-[#282828] text-white px-4 py-3 rounded-full outline-none focus:ring-2 focus:ring-white placeholder-gray-400"
/>
<button
type="submit"
disabled={isLoading}
className="bg-[#1db954] text-black font-semibold px-6 py-3 rounded-full hover:bg-[#1ed760] transition-colors disabled:opacity-50"
>
{isLoading ? "Searching..." : "Search"}
</button>
</div>
</form>
{!deviceId && (
<div className="bg-yellow-900/30 border border-yellow-700 text-yellow-200 px-4 py-3 rounded-lg mb-4">
<p className="text-sm">
Connecting to Spotify... Please wait a moment.
</p>
</div>
)}
{tracks.length > 0 && (
<div className="space-y-2">
{tracks.map((track, index) => (
<button
key={track.id}
onClick={() => playTrack(track.uri)}
disabled={!deviceId}
className="w-full flex items-center gap-4 p-3 rounded-lg hover:bg-[#282828] transition-colors text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="text-gray-400 w-6 text-right text-sm">
{index + 1}
</span>
{track.album.images[2] && (
<img
src={track.album.images[2].url}
alt={track.album.name}
className="w-10 h-10 rounded"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{track.name}</p>
<p className="text-gray-400 text-sm truncate">
{track.artists.map((a) => a.name).join(", ")}
</p>
</div>
<span className="text-gray-400 text-sm">
{track.album.name}
</span>
<span className="text-gray-400 text-sm w-12 text-right">
{formatDuration(track.duration_ms)}
</span>
</button>
))}
</div>
)}
</main>
);
}
Your search interface is pulling real data from Spotify's catalog. Next up, you'll connect the Web Playback SDK so those track buttons actually play music in your browser.
Integrate the Web Playback SDK
You can search for songs, but clicking them does nothing yet. Your app needs a way to actually stream audio in the browser.
The Spotify Web Playback SDK turns your browser tab into a Spotify Connect device. It registers alongside your phone and desktop app, and can receive full-quality audio streams. In this step, you'll load the SDK, initialize a player instance, and wire up a server-side route that tells Spotify which track to play.
In this step, get ready to:
- Create a PlayerProvider component that loads the SDK and manages playback state.
- Create a play Route Handler that sends playback commands to Spotify's API.
- Test full audio streaming by clicking a search result.
Create the PlayerProvider component
The PlayerProvider is a React context provider that wraps your entire app. It handles loading the SDK script, initializing the player, and exposing playback state to any child component that needs it.
- In VS Code, create a new file at components/PlayerProvider.tsx.
- Add the imports, type definitions, and context setup by pasting this code:
"use client";
import { createContext, useContext, useEffect, useState, useRef } from "react";
import PlayerBar from "./PlayerBar";
// Shape of a track object from the SDK's state
interface Track {
name: string;
artists: { name: string }[];
album: {
name: string;
images: { url: string }[];
};
duration_ms: number;
}
// What the context exposes to consumers
interface PlayerContextType {
player: Spotify.Player | null;
deviceId: string | null;
currentTrack: Track | null;
isPaused: boolean;
position: number;
duration: number;
playTrack: (uri: string) => Promise<void>;
}
// Default context values before the SDK connects
const PlayerContext = createContext<PlayerContextType>({
player: null,
deviceId: null,
currentTrack: null,
isPaused: true,
position: 0,
duration: 0,
playTrack: async () => {},
});
// Hook for child components to access player state
export const usePlayer = () => useContext(PlayerContext);
// The SDK attaches itself to the window object
declare global {
interface Window {
onSpotifyWebPlaybackSDKReady: () => void;
Spotify: typeof Spotify;
}
}
What does this code do?
The Track interface defines the shape of track data you'll extract from the SDK's state events. The PlayerContextType interface declares everything the provider shares with child components.
The declare global block tells TypeScript about two properties the SDK adds to the window object at runtime. Without this, TypeScript would complain about window.Spotify not existing.
- Below the global declaration, add the component function with its state hooks:
export default function PlayerProvider({
accessToken,
children,
}: {
accessToken: string;
children: React.ReactNode;
}) {
const [player, setPlayer] = useState<Spotify.Player | null>(null);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
const [isPaused, setIsPaused] = useState(true);
const [position, setPosition] = useState(0);
const [duration, setDuration] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
What are these state values for?
- player holds the SDK's Player instance so you can call methods like togglePlay() and seek().
- deviceId is the unique ID Spotify assigns to your browser player. You need it to tell Spotify where to send audio.
- intervalRef tracks a setInterval timer that updates the playback position every second during playback.
- Next, add the main useEffect that loads the SDK script and initializes the player. Paste this inside the component, below the state hooks:
useEffect(() => {
const script = document.createElement("script");
script.src = "https://sdk.scdn.co/spotify-player.js";
script.async = true;
document.body.appendChild(script);
window.onSpotifyWebPlaybackSDKReady = () => {
const spotifyPlayer = new window.Spotify.Player({
name: "Next.js Spotify Player",
getOAuthToken: (cb) => {
cb(accessToken);
},
volume: 0.5,
});
spotifyPlayer.addListener("ready", ({ device_id }) => {
console.log("Ready with Device ID", device_id);
setDeviceId(device_id);
});
spotifyPlayer.addListener("not_ready", ({ device_id }) => {
console.log("Device ID has gone offline", device_id);
});
spotifyPlayer.addListener("player_state_changed", (state) => {
if (!state) return;
const track = state.track_window.current_track;
setCurrentTrack({
name: track.name,
artists: track.artists,
album: {
name: track.album.name,
images: track.album.images,
},
duration_ms: state.duration,
});
setIsPaused(state.paused);
setPosition(state.position);
setDuration(state.duration);
});
spotifyPlayer.connect();
setPlayer(spotifyPlayer);
};
return () => {
if (player) {
player.disconnect();
}
};
}, []);
What does this effect do?
This effect dynamically injects the Spotify SDK script into the page. The SDK is not an npm package. It must be loaded as a script tag from https://sdk.scdn.co/spotify-player.js.
Once loaded, the SDK calls window.onSpotifyWebPlaybackSDKReady. Inside that callback, you create a new Spotify.Player instance with your access token and a device name. The ready event fires when Spotify accepts the connection and gives you a device_id.
The player_state_changed event fires every time playback changes. You extract the current track's name, artists, album art, position, and duration from state.track_window.current_track and store them in React state.
- Finally, add the position-tracking effect, the playTrack function, and the component's return statement. Paste this below the first useEffect:
useEffect(() => {
if (!isPaused) {
intervalRef.current = setInterval(() => {
setPosition((prev) => Math.min(prev + 1000, duration));
}, 1000);
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isPaused, duration]);
const playTrack = async (uri: string) => {
if (!deviceId) return;
await fetch("/api/play", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uri, deviceId }),
});
};
return (
<PlayerContext.Provider
value={{
player,
deviceId,
currentTrack,
isPaused,
position,
duration,
playTrack,
}}
>
<div className="pb-24">{children}</div>
<PlayerBar />
</PlayerContext.Provider>
);
}
What does this code do?
- The second useEffect runs a 1-second interval that increments position while the track is playing. This powers the seek bar's real-time movement. It clears the interval when playback is paused.
- playTrack sends a PUT request to your /api/play route with the track URI and device ID. That route forwards the command to Spotify's API.
- The pb-24 class on the children wrapper adds bottom padding so page content doesn't hide behind the fixed player bar.
- The PlayerProvider imports PlayerBar which doesn't exist yet. Create a placeholder file at components/PlayerBar.tsx so the app compiles:
"use client";
export default function PlayerBar() {
return (
<div className="fixed bottom-0 left-0 right-0 h-20 bg-[#282828] border-t border-gray-800 flex items-center justify-center">
<p className="text-gray-400 text-sm">
Search for a song and click to play
</p>
</div>
);
}
Why a placeholder?
Your PlayerProvider imports PlayerBar, but you'll build the full player bar in the next step. This minimal version renders a simple prompt at the bottom of the screen. It keeps the app compiling so you can test playback right now.
- Save both components/PlayerProvider.tsx and components/PlayerBar.tsx.
✔️ 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
"use client";
import { createContext, useContext, useEffect, useState, useRef } from "react";
import PlayerBar from "./PlayerBar";
interface Track {
name: string;
artists: { name: string }[];
album: {
name: string;
images: { url: string }[];
};
duration_ms: number;
}
interface PlayerContextType {
player: Spotify.Player | null;
deviceId: string | null;
currentTrack: Track | null;
isPaused: boolean;
position: number;
duration: number;
playTrack: (uri: string) => Promise<void>;
}
const PlayerContext = createContext<PlayerContextType>({
player: null,
deviceId: null,
currentTrack: null,
isPaused: true,
position: 0,
duration: 0,
playTrack: async () => {},
});
export const usePlayer = () => useContext(PlayerContext);
declare global {
interface Window {
onSpotifyWebPlaybackSDKReady: () => void;
Spotify: typeof Spotify;
}
}
export default function PlayerProvider({
accessToken,
children,
}: {
accessToken: string;
children: React.ReactNode;
}) {
const [player, setPlayer] = useState<Spotify.Player | null>(null);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
const [isPaused, setIsPaused] = useState(true);
const [position, setPosition] = useState(0);
const [duration, setDuration] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const script = document.createElement("script");
script.src = "https://sdk.scdn.co/spotify-player.js";
script.async = true;
document.body.appendChild(script);
window.onSpotifyWebPlaybackSDKReady = () => {
const spotifyPlayer = new window.Spotify.Player({
name: "Next.js Spotify Player",
getOAuthToken: (cb) => {
cb(accessToken);
},
volume: 0.5,
});
spotifyPlayer.addListener("ready", ({ device_id }) => {
console.log("Ready with Device ID", device_id);
setDeviceId(device_id);
});
spotifyPlayer.addListener("not_ready", ({ device_id }) => {
console.log("Device ID has gone offline", device_id);
});
spotifyPlayer.addListener("player_state_changed", (state) => {
if (!state) return;
const track = state.track_window.current_track;
setCurrentTrack({
name: track.name,
artists: track.artists,
album: {
name: track.album.name,
images: track.album.images,
},
duration_ms: state.duration,
});
setIsPaused(state.paused);
setPosition(state.position);
setDuration(state.duration);
});
spotifyPlayer.connect();
setPlayer(spotifyPlayer);
};
return () => {
if (player) {
player.disconnect();
}
};
}, []);
useEffect(() => {
if (!isPaused) {
intervalRef.current = setInterval(() => {
setPosition((prev) => Math.min(prev + 1000, duration));
}, 1000);
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isPaused, duration]);
const playTrack = async (uri: string) => {
if (!deviceId) return;
await fetch("/api/play", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uri, deviceId }),
});
};
return (
<PlayerContext.Provider
value={{
player,
deviceId,
currentTrack,
isPaused,
position,
duration,
playTrack,
}}
>
<div className="pb-24">{children}</div>
<PlayerBar />
</PlayerContext.Provider>
);
}
Create the play Route Handler
When a user clicks a track, playTrack sends the track URI to your server. You need a Route Handler that forwards this to Spotify's PUT /me/player/play endpoint with the access token.
- Create a new file at app/api/play/route.ts and paste this code:
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function PUT(request: NextRequest) {
const cookieStore = await cookies();
const accessToken = cookieStore.get("spotify_access_token")?.value;
if (!accessToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Extract the track URI and target device from the request body
const body = await request.json();
const { uri, deviceId } = body;
// Tell Spotify to start playing this track on the browser device
const response = await fetch(
`https://api.spotify.com/v1/me/player/play?device_id=${deviceId}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
uris: [uri],
}),
}
);
if (!response.ok && response.status !== 204) {
return NextResponse.json(
{ error: "Failed to play" },
{ status: response.status }
);
}
return NextResponse.json({ success: true });
}
What does this route do?
This Route Handler reads the access token from the HTTP-only cookie (which the browser can't access directly). It then calls Spotify's playback endpoint with the track URI wrapped in a uris array and the device_id as a query parameter.
Spotify returns a 204 (no content) on success, so the handler checks for both non-OK status AND a non-204 status before treating it as an error.
- Save app/api/play/route.ts.
- Return to your browser at http://localhost:3000. Log out and log back in (so the SDK initializes fresh with your token).
- Wait a few seconds for the "Connecting to Spotify..." banner to disappear. Then search for a song and click any result.
- You should hear the track start playing through your browser. The bottom bar shows "Search for a song and click to play" for now.
Not hearing audio?
- Make sure you have a Spotify Premium account. The Web Playback SDK requires Premium to stream audio.
- Check your browser console for "Ready with Device ID" in the logs. If you don't see it, the SDK script may not have loaded. Check that your access token is valid by logging out and back in.
- Open Spotify on your phone or desktop app. Under "Connect to a device", you should see "Next.js Spotify Player" listed. If it's not there, the SDK connection failed.
Help me debug why my Spotify Web Playback SDK isn't playing audio in the browser.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function PUT(request: NextRequest) {
const cookieStore = await cookies();
const accessToken = cookieStore.get("spotify_access_token")?.value;
if (!accessToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { uri, deviceId } = body;
const response = await fetch(
`https://api.spotify.com/v1/me/player/play?device_id=${deviceId}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
uris: [uri],
}),
}
);
if (!response.ok && response.status !== 204) {
return NextResponse.json(
{ error: "Failed to play" },
{ status: response.status }
);
}
return NextResponse.json({ success: true });
}
Your browser is now a registered Spotify Connect device that can stream full audio. Next up, you'll build a proper player bar with play/pause, skip controls, a seek bar, and volume.
Build the Player Bar
Your search interface returns tracks from Spotify's catalog. But clicking a track plays it through the SDK without any visible feedback.
Every music player needs a control strip. In this step, you'll build a persistent player bar fixed to the bottom of the screen. It will show the current track's artwork, provide play/pause and skip controls, and let you scrub through the timeline and adjust volume.
In this step, get ready to:
- Create a persistent bottom player bar showing track info.
- Wire up play/pause, previous, and next buttons using the SDK.
- Add a seek bar and volume slider for fine-grained playback control.
Create the player bar component
The PlayerProvider you built in the previous step already imports and renders a PlayerBar component. That component doesn't exist yet, so your dev server is showing an error.
- Create a new file called PlayerBar.tsx inside your components folder and add the following code:
"use client";
import { usePlayer } from "./PlayerProvider";
function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000);
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
export default function PlayerBar() {
const { player, currentTrack, isPaused, position, duration } = usePlayer();
if (!currentTrack) {
return (
<div className="fixed bottom-0 left-0 right-0 h-20 bg-[#282828] border-t border-gray-800 flex items-center justify-center">
<p className="text-gray-400 text-sm">
Search for a song and click to play
</p>
</div>
);
}
const albumArt = currentTrack.album.images[0]?.url;
const progressPercent = duration > 0 ? (position / duration) * 100 : 0;
const handleTogglePlay = () => {
player?.togglePlay();
};
const handlePrevious = () => {
player?.previousTrack();
};
const handleNext = () => {
player?.nextTrack();
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPosition = Number(e.target.value);
player?.seek(newPosition);
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = Number(e.target.value) / 100;
player?.setVolume(newVolume);
};
return (
<div className="fixed bottom-0 left-0 right-0 h-24 bg-[#282828] border-t border-gray-800 px-4 flex items-center">
{/* Track Info */}
<div className="flex items-center w-1/4 min-w-0">
{albumArt && (
<img
src={albumArt}
alt={currentTrack.album.name}
className="w-14 h-14 rounded mr-3"
/>
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">{currentTrack.name}</p>
<p className="text-xs text-gray-400 truncate">
{currentTrack.artists.map((a) => a.name).join(", ")}
</p>
</div>
</div>
{/* Playback Controls */}
<div className="flex flex-col items-center flex-1">
<div className="flex items-center gap-4 mb-1">
<button
onClick={handlePrevious}
className="text-gray-400 hover:text-white transition-colors"
aria-label="Previous track"
>
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M3.3 1a.7.7 0 0 1 .7.7v5.15l9.95-5.744a.7.7 0 0 1 1.05.606v12.575a.7.7 0 0 1-1.05.607L4 9.149V14.3a.7.7 0 0 1-.7.7H2.7a.7.7 0 0 1-.7-.7V1.7a.7.7 0 0 1 .7-.7h.6z" />
</svg>
</button>
<button
onClick={handleTogglePlay}
className="w-8 h-8 bg-white rounded-full flex items-center justify-center hover:scale-105 transition-transform"
aria-label={isPaused ? "Play" : "Pause"}
>
{isPaused ? (
<svg width="16" height="16" fill="black" viewBox="0 0 16 16">
<path d="M3 1.713a.7.7 0 0 1 1.05-.607l10.89 6.288a.7.7 0 0 1 0 1.212L4.05 14.894A.7.7 0 0 1 3 14.288V1.713z" />
</svg>
) : (
<svg width="16" height="16" fill="black" viewBox="0 0 16 16">
<path d="M2.7 1a.7.7 0 0 0-.7.7v12.6a.7.7 0 0 0 .7.7h2.6a.7.7 0 0 0 .7-.7V1.7a.7.7 0 0 0-.7-.7H2.7zm8 0a.7.7 0 0 0-.7.7v12.6a.7.7 0 0 0 .7.7h2.6a.7.7 0 0 0 .7-.7V1.7a.7.7 0 0 0-.7-.7h-2.6z" />
</svg>
)}
</button>
<button
onClick={handleNext}
className="text-gray-400 hover:text-white transition-colors"
aria-label="Next track"
>
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.7 1a.7.7 0 0 0-.7.7v5.15L2.05 1.107A.7.7 0 0 0 1 1.712v12.575a.7.7 0 0 0 1.05.607L12 9.149V14.3a.7.7 0 0 0 .7.7h.6a.7.7 0 0 0 .7-.7V1.7a.7.7 0 0 0-.7-.7h-.6z" />
</svg>
</button>
</div>
<div className="flex items-center gap-2 w-full max-w-md">
<span className="text-xs text-gray-400 w-10 text-right">
{formatTime(position)}
</span>
<input
type="range"
min={0}
max={duration}
value={position}
onChange={handleSeek}
className="flex-1"
/>
<span className="text-xs text-gray-400 w-10">
{formatTime(duration)}
</span>
</div>
</div>
{/* Volume */}
<div className="flex items-center w-1/4 justify-end">
<svg
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
className="text-gray-400 mr-2"
>
<path d="M9.741.85a.75.75 0 0 1 .375.65v13a.75.75 0 0 1-1.125.65l-6.925-4a3.642 3.642 0 0 1-1.33-4.967 3.639 3.639 0 0 1 1.33-1.332l6.925-4a.75.75 0 0 1 .75 0z" />
<path d="M11.5 4.75a.75.75 0 0 1 1.145-.638 5.713 5.713 0 0 1 0 7.776.75.75 0 1 1-1.145-.976 4.214 4.214 0 0 0 0-5.824.75.75 0 0 1 0-.338z" />
</svg>
<input
type="range"
min={0}
max={100}
defaultValue={50}
onChange={handleVolumeChange}
className="w-24"
/>
</div>
</div>
);
}
What does this code do?
This component is structured into four main sections:
- formatTime() converts milliseconds to a mm:ss string for the seek bar timestamps.
- The empty state (if (!currentTrack)) renders a placeholder bar before any track is playing.
- Handler functions call SDK methods like togglePlay(), previousTrack(), nextTrack(), seek(), and setVolume() directly on the player instance.
- The return JSX has three columns: track info on the left, playback controls in the center, and volume on the right.
- Save components/PlayerBar.tsx.
- Return to your browser. You should see a dark bar fixed to the bottom of the page with the text "Search for a song and click to play".
Don't see the player bar?
- Check that the file is named exactly PlayerBar.tsx (capital P, capital B) inside the components folder. The import in PlayerProvider.tsx is case-sensitive.
- Make sure the file starts with "use client"; on line 1. Without this directive, React hooks will fail.
- If the dev server shows a compile error, check for any missing closing braces or parentheses in the JSX.
My player bar isn't appearing at the bottom of the page.
Test the playback controls
The player bar is visible, but you need a track playing to see the full controls in action. Time to do an end-to-end test.
- In the search field, type the name of a song you like and click Search.
- Click any track in the results list.
- Wait a moment for the SDK to start playback. The player bar should update to show the track's album art, name, and artist.
- Click the white circular play/pause button in the center. The track should pause, and the icon should swap from a pause symbol to a play triangle.
- Click the skip-forward button (to the right of play/pause). The track stops since there's no queue.
How do the SDK methods work?
Each button calls a method directly on the Spotify.Player instance that you initialized in PlayerProvider. The SDK handles all communication with Spotify's servers for you.
- togglePlay() pauses if playing, resumes if paused.
- previousTrack() and nextTrack() navigate the playback queue.
- The player_state_changed event (listened to in PlayerProvider) fires every time state changes, keeping the UI in sync.
Track not playing when clicked?
- Wait for the yellow "Connecting to Spotify..." banner to disappear. The SDK needs a few seconds to register your browser as a device.
- Make sure your Spotify Premium account is not actively playing on another device. Only one device can stream at a time unless you transfer playback.
- Check the browser console for errors. A 403 status from the play endpoint usually means your access token expired. Log out and log back in to refresh it.
I click a track but nothing plays and the player bar stays empty.
Verify seeking and volume
The seek bar and volume slider give you fine control over playback position and audio level.
- With a track playing, drag the seek bar (the horizontal slider in the center) to a different position. The track should jump to that timestamp.
- Watch the timestamps on either side of the seek bar. The left side shows current position and the right shows total duration, both in mm:ss format.
- Drag the volume slider on the right side. Audio volume should change immediately.
How do seeking and volume work?
The seek bar uses an input[type="range"] with min={0} and max={duration}. When you drag it, handleSeek calls player.seek(newPosition) which tells Spotify to jump to that millisecond in the track.
The volume slider ranges from 0 to 100. The handler divides by 100 to get a float between 0 and 1 before passing it to player.setVolume(). The SDK initialized at 0.5 (50%) by default.
Seek bar not updating in real time?
- The position counter updates once per second via the interval in PlayerProvider. If it's stuck, check that isPaused is false when music is playing (verify in React DevTools).
- If the bar jumps back after dragging, make sure your handleSeek function calls player?.seek(newPosition) with the numeric value from the input.
My seek bar or volume slider isn't responding when I drag them.
✔️ Awesome, I've got everything!
Great. Double check you've saved components/PlayerBar.tsx and your player is working end-to-end.
ⓧ I'd like to double check the full code
"use client";
import { usePlayer } from "./PlayerProvider";
function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000);
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
export default function PlayerBar() {
const { player, currentTrack, isPaused, position, duration } = usePlayer();
if (!currentTrack) {
return (
<div className="fixed bottom-0 left-0 right-0 h-20 bg-[#282828] border-t border-gray-800 flex items-center justify-center">
<p className="text-gray-400 text-sm">
Search for a song and click to play
</p>
</div>
);
}
const albumArt = currentTrack.album.images[0]?.url;
const progressPercent = duration > 0 ? (position / duration) * 100 : 0;
const handleTogglePlay = () => {
player?.togglePlay();
};
const handlePrevious = () => {
player?.previousTrack();
};
const handleNext = () => {
player?.nextTrack();
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPosition = Number(e.target.value);
player?.seek(newPosition);
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = Number(e.target.value) / 100;
player?.setVolume(newVolume);
};
return (
<div className="fixed bottom-0 left-0 right-0 h-24 bg-[#282828] border-t border-gray-800 px-4 flex items-center">
{/* Track Info */}
<div className="flex items-center w-1/4 min-w-0">
{albumArt && (
<img
src={albumArt}
alt={currentTrack.album.name}
className="w-14 h-14 rounded mr-3"
/>
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">{currentTrack.name}</p>
<p className="text-xs text-gray-400 truncate">
{currentTrack.artists.map((a) => a.name).join(", ")}
</p>
</div>
</div>
{/* Playback Controls */}
<div className="flex flex-col items-center flex-1">
<div className="flex items-center gap-4 mb-1">
<button
onClick={handlePrevious}
className="text-gray-400 hover:text-white transition-colors"
aria-label="Previous track"
>
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M3.3 1a.7.7 0 0 1 .7.7v5.15l9.95-5.744a.7.7 0 0 1 1.05.606v12.575a.7.7 0 0 1-1.05.607L4 9.149V14.3a.7.7 0 0 1-.7.7H2.7a.7.7 0 0 1-.7-.7V1.7a.7.7 0 0 1 .7-.7h.6z" />
</svg>
</button>
<button
onClick={handleTogglePlay}
className="w-8 h-8 bg-white rounded-full flex items-center justify-center hover:scale-105 transition-transform"
aria-label={isPaused ? "Play" : "Pause"}
>
{isPaused ? (
<svg width="16" height="16" fill="black" viewBox="0 0 16 16">
<path d="M3 1.713a.7.7 0 0 1 1.05-.607l10.89 6.288a.7.7 0 0 1 0 1.212L4.05 14.894A.7.7 0 0 1 3 14.288V1.713z" />
</svg>
) : (
<svg width="16" height="16" fill="black" viewBox="0 0 16 16">
<path d="M2.7 1a.7.7 0 0 0-.7.7v12.6a.7.7 0 0 0 .7.7h2.6a.7.7 0 0 0 .7-.7V1.7a.7.7 0 0 0-.7-.7H2.7zm8 0a.7.7 0 0 0-.7.7v12.6a.7.7 0 0 0 .7.7h2.6a.7.7 0 0 0 .7-.7V1.7a.7.7 0 0 0-.7-.7h-2.6z" />
</svg>
)}
</button>
<button
onClick={handleNext}
className="text-gray-400 hover:text-white transition-colors"
aria-label="Next track"
>
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.7 1a.7.7 0 0 0-.7.7v5.15L2.05 1.107A.7.7 0 0 0 1 1.712v12.575a.7.7 0 0 0 1.05.607L12 9.149V14.3a.7.7 0 0 0 .7.7h.6a.7.7 0 0 0 .7-.7V1.7a.7.7 0 0 0-.7-.7h-.6z" />
</svg>
</button>
</div>
<div className="flex items-center gap-2 w-full max-w-md">
<span className="text-xs text-gray-400 w-10 text-right">
{formatTime(position)}
</span>
<input
type="range"
min={0}
max={duration}
value={position}
onChange={handleSeek}
className="flex-1"
/>
<span className="text-xs text-gray-400 w-10">
{formatTime(duration)}
</span>
</div>
</div>
{/* Volume */}
<div className="flex items-center w-1/4 justify-end">
<svg
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
className="text-gray-400 mr-2"
>
<path d="M9.741.85a.75.75 0 0 1 .375.65v13a.75.75 0 0 1-1.125.65l-6.925-4a3.642 3.642 0 0 1-1.33-4.967 3.639 3.639 0 0 1 1.33-1.332l6.925-4a.75.75 0 0 1 .75 0z" />
<path d="M11.5 4.75a.75.75 0 0 1 1.145-.638 5.713 5.713 0 0 1 0 7.776.75.75 0 1 1-1.145-.976 4.214 4.214 0 0 0 0-5.824.75.75 0 0 1 0-.338z" />
</svg>
<input
type="range"
min={0}
max={100}
defaultValue={50}
onChange={handleVolumeChange}
className="w-24"
/>
</div>
</div>
);
}
Your Spotify web player is fully functional. You can search for any song, stream it in full, control playback, scrub through the timeline, and adjust volume. Your browser is now a legitimate Spotify Connect device. Next up, you'll tackle the Secret Mission and build a personalized Top Tracks page.
Secret mission
Your Top Tracks
Your player can search and play any song. But what about your personal listening history? Add a dedicated Top Tracks page that shows your most-played songs and lets you play them with one click.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine and uses your existing Spotify Premium subscription. There are no ongoing cloud costs.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Spotify Developer App (registered in the Spotify Developer Dashboard).
- Next.js project folder on your Desktop.
- Next.js dev server (running process in your terminal).
Keep everything running
No action needed. Choose this if you're still actively building or want to keep your custom player ready to use.
- Your dev server stays running at http://localhost:3000.
- Your Spotify Developer App remains active in the Dashboard.
- Run npm run dev from your project folder any time you want to play music through your custom player.
Pause - I'll come back to this later
Stop the dev server to free up your terminal. Your code and Spotify app stay intact for next time.
- Switch to the terminal where your dev server is running.
- Press Ctrl+C to stop the server.
- Your project folder and Spotify Developer App remain untouched. When you want to resume, run npm run dev from your project folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your local code and your Spotify Developer App.
- Stop the dev server by pressing Ctrl+C in the terminal.
- Delete your Spotify Developer App:
- Go to https://developer.spotify.com/dashboard.
- Click the name of your app to open the app overview page.
- Click the red DELETE button at the bottom of the page.
Delete your project folder from the Desktop. On macOS or Linux, run:
rm -rf ~/Desktop/spotify-web-player
On Windows, run:
Remove-Item -Recurse -Force ~\Desktop\spotify-web-player
Used a different project name?
If you named your project something other than spotify-web-player when running create-next-app, replace it with whatever name you chose.
Nice Work!
Nice Work!
You just built your own Spotify web player from scratch. Your browser is now a fully functional Spotify Connect device that can search the catalog, stream full tracks, and control playback in real time.
You've learned how to:
- Implemented the OAuth 2.0 Authorization Code flow with secure server-side token management in Next.js Route Handlers.
- Integrated the Spotify Web API to query the music catalog and display track results with album art.
- Connected the Web Playback SDK to stream full tracks in the browser with a real-time player bar featuring play/pause, skip, seek, and volume controls.
- Added a personalized Top Tracks page that displays your most-played songs and lets you play them directly.
Ready to quiz yourself?