EDUCATIONOverviewStudentsTeachersSchoolsCurriculumProject Library
GUIDED PROJECT·Intermediate·~3 weeks·14 min read

Build a Spotify Clone

Build a music app that looks and feels like the real thing — browse, search, and preview tracks using the real Spotify Web API. You'll implement a proper OAuth flow, build a complex responsive UI (sidebar, now-playing bar, search), and wire up audio playback with state that stays in sync.

WHAT YOU'LL WALK AWAY WITH
  • A polished, recognizable music app powered by the real Spotify catalog
  • A working OAuth (PKCE) flow — how a real app gets permission to call an API on a user's behalf, without a secret in the browser
  • A complex, responsive UI: sidebar, now-playing bar, search, and track lists
  • Audio preview playback and the state that keeps the player in sync with the UI
TIER
Intermediate
TIME
~3 weeks
SKILLS
Complex UI, media, third-party APIs
STACK
React, Spotify Web API
PORTFOLIO
A polished, recognizable showpiece
REQUIRES
One intermediate project

A Spotify clone is a rare portfolio piece: instantly recognizable, visually impressive, and — done honestly — genuinely hard in the ways that matter. Anyone can screenshot a dark sidebar. The real engineering is getting a browser app to authenticate against a real API safely, and keeping a non-trivial UI in sync when a click in one corner has to change three others. That's the intermediate leap: from "a page that fetches data" to "an application with state."

This assumes you've finished at least one intermediate project — something like the Expense Tracker from the project library, where you managed real state across components. Honest scope note: this is a clone, not Spotify. You'll browse, search, and play 30-second previews — not stream full songs (Spotify doesn't allow that), and not rebuild their recommendation engine. The constraint is the point: it keeps your attention on UI state and auth, the two skills worth the three weeks.

Info

Estimated time: ~3 weeks, part-time. Not because the code is enormous, but because two things here are genuinely new and deserve real attention: the OAuth PKCE flow (a security dance with several moving parts) and complex shared state (the reason this project is rated intermediate). Rush either and you'll get a UI that mostly works and completely falls apart the moment you try to change it.

What you're building

A single-page music app with three regions that are always in agreement: a left sidebar for navigation, a main view that shows search results or a browse grid, and a now-playing bar pinned to the bottom that reflects whatever track is currently loaded. Click a track in the main view and the now-playing bar updates; press play there and the main view's row shows it playing. One source of truth, three views onto it.

Stack: React (via Vite, so it stays a fast, plain single-page app with no server to run) and the real Spotify Web API for the catalog. Why the real API instead of a canned JSON file? Because the whole point is learning to authenticate against and consume a production API on someone else's terms — rate limits, token expiry, and all. A fake data file would teach you nothing you don't already know.

Tip

Build the UI with mock data first if it helps — a hardcoded array of fake tracks — then swap in the real API once the layout and state are solid. Separating "does my UI work" from "does my API call work" means that when something breaks, you know which half to look at. That's a debugging discipline worth more than any single feature here.

Note

Spotify's API is for learning and personal use. Respect their developer terms — don't monetize a clone, don't pass it off as an official product, and don't try to route around the preview-only limitation. This project is a portfolio piece and a learning vehicle, nothing more.

Milestone 0 — Setup: a React app and a Spotify developer app

Two things to create: your front end and your Spotify credentials. Scaffold the app first:

npm create vite@latest spotify-clone -- --template react-ts
cd spotify-clone
npm install

Then go to the Spotify Developer Dashboard, create an app, and note two things: your Client ID (public, safe to ship) and the Redirect URI you register — for local dev, use http://127.0.0.1:5173/callback. Put the client ID in a .env file:

# .env
VITE_SPOTIFY_CLIENT_ID=your_client_id_here
VITE_REDIRECT_URI=http://127.0.0.1:5173/callback

Why there's a Client ID here but no Client Secret: Spotify issues both, and you will be tempted to use the secret. Don't — not in this app. A browser ships all its code to the user; anything in it is public. The client ID is meant to be public (it just names your app), but the secret is a real credential. This is the exact same secret boundary the AI Chatbot project teaches: a browser can't keep a secret. There, the answer was a server. Here, it's a different tool — PKCE — which is Milestone 1.

Milestone 1 — Auth: the Authorization Code with PKCE flow

You need a token to call the API, and you need to get one without a secret. That's precisely the problem PKCE (Proof Key for Code Exchange) solves. The flow: your app invents a random secret for this one login only, sends a hashed version to Spotify, the user approves, and your app proves it owns the original to exchange the code for a token. No long-lived secret ever ships in your code.

// auth.ts — kick off the login
function randomString(len: number) {
  const bytes = crypto.getRandomValues(new Uint8Array(len));
  return btoa(String.fromCharCode(...bytes)).replace(/[^a-zA-Z0-9]/g, "").slice(0, len);
}

async function sha256(plain: string) {
  const data = new TextEncoder().encode(plain);
  const hash = await crypto.subtle.digest("SHA-256", data);
  return btoa(String.fromCharCode(...new Uint8Array(hash)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export async function login() {
  const verifier = randomString(64);
  const challenge = await sha256(verifier);
  localStorage.setItem("pkce_verifier", verifier); // needed after redirect

  const params = new URLSearchParams({
    client_id: import.meta.env.VITE_SPOTIFY_CLIENT_ID,
    response_type: "code",
    redirect_uri: import.meta.env.VITE_REDIRECT_URI,
    scope: "user-read-private",
    code_challenge_method: "S256",
    code_challenge: challenge,
  });
  window.location.href = `https://accounts.spotify.com/authorize?${params}`;
}

After the user approves, Spotify redirects back to /callback with a code. You exchange it — sending the original verifier, not the hash — for an access token.

What to understand, because this is the milestone people paste and never grasp:

  • The verifier is a one-time secret your app generates and never sends until the final step. Spotify first sees only its hash (the challenge). At the exchange, you reveal the verifier; Spotify re-hashes it and checks it matches. That proves the app finishing the login is the same one that started it — no shared secret required.
  • The token comes back to your browser and lives there. That's why the client ID being public is fine: possessing it doesn't let an attacker get a token, because they can't complete the PKCE proof for your redirect URI.
Heads up

Access tokens expire — usually in an hour — and your app must handle that. A token that worked at login will start returning 401 mid-session, and if you don't plan for it, your app silently dies. The token exchange also returns a refresh_token; use it to get a fresh access token when the old one expires (store the expiry time, check it before each call, refresh if needed). Building this in from the start is far easier than retrofitting it after every request starts failing an hour into testing.

Milestone 2 — The layout shell: sidebar, main view, and now-playing bar

Before any data, build the three-region skeleton — because the layout is what dictates your state structure. Lay it out with CSS grid so the sidebar and now-playing bar are fixed and the main view scrolls:

// App.tsx
export default function App() {
  return (
    <div className="app">
      <aside className="sidebar">{/* nav: Home, Search, Library */}</aside>
      <main className="main-view">{/* search results or browse grid */}</main>
      <footer className="now-playing">{/* current track + controls */}</footer>
    </div>
  );
}
.app {
  display: grid;
  grid-template-columns: 240px 1fr;
  grid-template-rows: 1fr 90px;
  grid-template-areas: "sidebar main" "now-playing now-playing";
  height: 100vh;
}
.sidebar { grid-area: sidebar; }
.main-view { grid-area: main; overflow-y: auto; }
.now-playing { grid-area: now-playing; }

Why start here, and why it matters more than it looks: the now-playing bar and a track row in the main view both need to know the same thing — what's playing and whether it's paused. That shared need is the central design decision of this whole project. The wrong move is to give each region its own copy of that state; they'll drift out of sync the first time you click something. The right move is one source of truth, lifted to a common parent (or a small store / React context), that every region reads from and dispatches to. Decide that now, with an empty shell, when it's cheap — not after you've wired three components to three different useState calls.

Milestone 3 — Search and render tracks from the API

Now data. With a token in hand, hit the search endpoint and render results in the main view. Build a tiny wrapper so every call carries the token and handles a 401 in one place:

// api.ts
export async function spotifyGet(path: string, token: string) {
  const res = await fetch(`https://api.spotify.com/v1/${path}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (res.status === 401) throw new Error("token_expired"); // trigger a refresh
  if (!res.ok) throw new Error(`Spotify API error: ${res.status}`);
  return res.json();
}

export async function searchTracks(query: string, token: string) {
  const q = encodeURIComponent(query);
  const data = await spotifyGet(`search?q=${q}&type=track&limit=20`, token);
  return data.tracks.items; // an array of track objects
}

Render the results, and — the habit from every project in this library — log one track object and read its real shape before you write the JSX. You'll find name, an artists array, an album.images array, and preview_url. You didn't guess those; you looked.

function TrackList({ tracks, onSelect }) {
  return (
    <ul>
      {tracks.map((t) => (
        <li key={t.id} onClick={() => onSelect(t)}>
          <img src={t.album.images[2]?.url} alt="" width={40} height={40} />
          <span>{t.name}</span>
          <span>{t.artists.map((a) => a.name).join(", ")}</span>
        </li>
      ))}
    </ul>
  );
}

What to understand: the onSelect callback is the seam between this milestone and the state you set up in Milestone 2. Clicking a row doesn't play anything yet — it selects a track, which updates the one source of truth, which the now-playing bar reads. Keeping selection (a UI concern) separate from the data-fetching keeps each piece testable and swappable. That separation is what "component structure" actually means in practice.

Heads up

A track's preview_url can be null — Spotify doesn't have a preview for every track, and this will absolutely happen in your search results. If you assume it's always a string and pass null to an audio element, playback silently does nothing and you'll waste an evening debugging the wrong layer. Check for it: disable or visually dim the play button for tracks without a preview, and show a small "No preview available" hint. Handling the missing case on purpose is the intermediate move.

Milestone 4 — Audio preview playback and player state

Time to make it play. A preview is just an MP3 URL, so a single Audio element does the work — the interesting part is keeping player state (which track, playing vs. paused) in sync with the UI. Model that state in one place:

// usePlayer.ts — one source of truth for playback
import { useRef, useState } from "react";

export function usePlayer() {
  const audioRef = useRef<HTMLAudioElement | null>(null);
  const [current, setCurrent] = useState(null); // the track object, or null
  const [isPlaying, setIsPlaying] = useState(false);

  function play(track) {
    if (!track.preview_url) return; // Milestone 3's gotcha, enforced here too
    if (!audioRef.current) audioRef.current = new Audio();
    if (current?.id !== track.id) {
      audioRef.current.src = track.preview_url;
      setCurrent(track);
    }
    audioRef.current.play();
    setIsPlaying(true);
  }

  function toggle() {
    if (!audioRef.current || !current) return;
    if (isPlaying) audioRef.current.pause();
    else audioRef.current.play();
    setIsPlaying(!isPlaying);
  }

  return { current, isPlaying, play, toggle };
}

Call usePlayer once, high in the tree, and pass current, isPlaying, and the functions down to both the track list and the now-playing bar. Now a click in the list and the play button in the bar drive the same state, so they can never disagree.

What to understand — this is the heart of the whole project:

  • The Audio object lives in a ref, not in state. The audio element itself isn't rendered; changing it shouldn't re-render React. State is for what the UI displays (current, isPlaying); the ref is for the imperative thing you command. Knowing which is which is a genuinely intermediate React skill.
  • One hook, many consumers. Because both regions read isPlaying from the same hook, the pause icon in the bar and the equalizer animation on the list row flip together for free. That's what "one source of truth" buys you — and it's exactly the sync problem that makes people rate this project intermediate.

Milestone 5 — Polish, responsive layout, and ship it

A demo becomes a showpiece at the edges. Layer these in, and make sure you can explain what each adds:

  • Responsive layout. On a narrow screen the fixed sidebar has to collapse (a hamburger toggle, or hide it below a breakpoint) while the now-playing bar stays pinned. Use CSS media queries to restructure the grid — the grid-template-areas from Milestone 2 makes this a few lines, not a rewrite.
  • Real states. Empty search, loading spinners, and a friendly message when a query returns nothing. Same discipline as every project here: the happy path is the easy 80%.
  • The details that sell it. A progress bar that follows the 30-second preview, keyboard space-to-pause, hover states on rows. These are small and high-impact for a portfolio screenshot.

Then ship it. Because it's a static single-page app, deploying is nearly free — push to GitHub and connect it to Netlify or Vercel, both of which build a Vite app with zero config.

Heads up

Your production redirect URI must be registered in the Spotify dashboard, or login will fail with a mismatch error the moment you deploy. Add your live URL's callback (e.g. https://your-app.netlify.app/callback) to the app's Redirect URIs in the dashboard, and set your VITE_REDIRECT_URI env var in the host's settings to match. The redirect URI is part of the PKCE security check — Spotify only sends the code back to a URI you've pre-approved, which is why it can't be spoofed.

Stuck? Read the source of truth

When something won't click, the answer is almost always in the official docs — not another tutorial's paraphrase:

  • Spotify Web API — the official reference for every endpoint you'll call (search, tracks, playback) and the full Authorization Code with PKCE flow this build uses. When an endpoint returns something you didn't expect, or a scope is missing, this is where the answer lives.

Reading the real API docs — finding the one parameter or scope you need in a dense reference — is the skill being trained here, and it outlasts any single API.

Build it with Cartara

Here's the honest part. You could get this whole thing running by pasting each block above — the PKCE dance, the usePlayer hook, the grid layout — and never actually understand why the verifier stays secret until the last step, or why the audio element belongs in a ref instead of state. It would work. You'd also be unable to change it, debug the 401 when your token expires, or answer an interviewer's "how does your login flow keep the token safe?" And increasingly an AI assistant will have written large parts of it for you, which makes that gap invisible until it bites.

That gap is exactly what Cartara exists to close. As you build:

  • Point Cartara at your repo. It watches the code that lands — including anything an AI assistant generated — and turns each meaningful change into a checkpoint for understanding, not just a commit.
  • Explain your changes back. When you wire up the PKCE flow or lift player state to one source of truth, Cartara asks you the why and the trade-offs — the same questions a reviewer or interviewer would.
  • See your comprehension, not just your output. You get an honest read on which parts of your own app you actually understand — the auth flow, the shared state, the ref-vs-state call — so you can shore up the weak spots before they surface in a review.

That's the loop this whole library is built around: build something real, then prove to yourself you understand it. The Spotify clone is the project. The understanding is the point.

Turn a showpiece build into understanding you can prove

Point Cartara at your Spotify-clone repo and it captures what you genuinely learned — the honest way to make an impressive, AI-assisted project truly yours.

Join the waitlist

Reference implementations

Production code worth studying — afteryou've built your own version.

Spotify Web API
The official API reference — every endpoint you'll call (search, tracks, playback) and the Authorization Code with PKCE flow this build uses.
← All projects

Build it with Cartara

Point Cartara at your repo as you build this project. It captures what you actually learned from every change — including the code an AI assistant wrote — so an AI-assisted build becomes understanding you can prove.

Join the waitlist