stephen.
All posts
Tutorial

How I Built a Spotify Now Playing Widget for My Portfolio

The full walkthrough — Spotify OAuth, a Next.js API route, and a Framer Motion UI that actually feels good.

17 May 2025·7 min read

There's a small widget in the About section of my portfolio that shows whatever I'm listening to on Spotify in real time. Hover it and you get the album art, a live progress bar, and the track title scrolling if it's too long. The album art spins.

It's a small thing, but it's the detail people mention first. Here's how I built it.


How it works

The high-level flow:

  1. Spotify's API has a "currently playing" endpoint
  2. That endpoint requires an access token, which expires every hour
  3. We get a long-lived refresh token once (via OAuth) and use it to mint fresh access tokens on the server
  4. A Next.js API route handles the token refresh + data fetch on every request
  5. The frontend polls that route every 5 seconds using SWR

No third-party wrappers. About 100 lines of code total.


Step 1 — Create a Spotify app

Go to developer.spotify.com/dashboard and create a new app.

Under Redirect URIs, add:

https://yourdomain.com/callback
http://localhost:3000/callback

Note your Client ID and Client Secret.


Step 2 — Get your refresh token

This is a one-time OAuth dance. The result is a refresh token you'll store as an environment variable forever.

Build the authorization URL:

https://accounts.spotify.com/authorize
  ?client_id=YOUR_CLIENT_ID
  &response_type=code
  &redirect_uri=https://yourdomain.com/callback
  &scope=user-read-currently-playing

Visit it in your browser, authorize the app, and Spotify redirects to /callback?code=SOME_CODE.

Create the exchange-token API route at app/api/exchange-token/route.ts:

import { NextResponse } from "next/server";

export async function POST(request: Request) {
  const { code } = await request.json();

  const authHeader =
    "Basic " +
    Buffer.from(
      `${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}`,
    ).toString("base64");

  const response = await fetch("https://accounts.spotify.com/api/token", {
    method: "POST",
    headers: {
      Authorization: authHeader,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: process.env.SPOTIFY_REDIRECT_URI!,
    }),
  });

  const data = await response.json();
  return NextResponse.json(data);
}

Your /callback page sends the code to this route, receives the refresh_token, and displays it. Copy it into .env.local:

SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
SPOTIFY_REFRESH_TOKEN=your_refresh_token
SPOTIFY_REDIRECT_URI=https://yourdomain.com/callback

Step 3 — The Now Playing API route

Create app/api/now-playing/route.ts. This gets called every 5 seconds by the frontend:

import { NextResponse } from "next/server";

async function getAccessToken() {
  const basic = Buffer.from(
    `${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}`,
  ).toString("base64");

  const response = await fetch("https://accounts.spotify.com/api/token", {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: process.env.SPOTIFY_REFRESH_TOKEN!,
    }),
  });

  return response.json();
}

export async function GET() {
  try {
    const { access_token } = await getAccessToken();

    const res = await fetch(
      "https://api.spotify.com/v1/me/player/currently-playing",
      {
        headers: { Authorization: `Bearer ${access_token}` },
        next: { revalidate: 0 },
      },
    );

    // 204 = nothing playing
    if (res.status === 204 || res.status > 400) {
      return NextResponse.json({ isPlaying: false });
    }

    const song = await res.json();

    return NextResponse.json({
      isPlaying: song.is_playing,
      title: song.item.name,
      artist: song.item.artists.map((a: { name: string }) => a.name).join(", "),
      albumImageUrl: song.item.album.images[0].url,
      songUrl: song.item.external_urls.spotify,
      duration: song.item.duration_ms,
      progress: song.progress_ms,
    });
  } catch {
    return NextResponse.json({ isPlaying: false });
  }
}

A 204 means nothing is playing. Anything above 400 is an error. Both return { isPlaying: false } — the widget degrades gracefully either way.


Step 4 — The SWR hook

pnpm add swr

Create hooks/useNowPlaying.ts:

import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export function useNowPlaying() {
  const { data, error } = useSWR("/api/now-playing", fetcher, {
    refreshInterval: 5000,
  });

  return {
    isLoading: !data && !error,
    isPlaying: data?.isPlaying ?? false,
    title: data?.title ?? "",
    artist: data?.artist ?? "",
    albumImageUrl: data?.albumImageUrl ?? "",
    songUrl: data?.songUrl ?? "",
    duration: data?.duration ?? 0,
    progress: data?.progress ?? 0,
  };
}

SWR handles polling, deduplication, and revalidation on focus. You get a fresh snapshot every 5 seconds without any extra work.


Step 5 — The UI

pnpm add framer-motion

The widget has two parts: a compact pill (always visible) and a hover card with the full detail.

"use client";

import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
import Image from "next/image";
import { ExternalLink } from "lucide-react";
import { useNowPlaying } from "@/hooks/useNowPlaying";

function SpotifyBars() {
  return (
    <span className="flex items-end gap-[2px] h-3.5">
      {[1, 2, 3].map((i) => (
        <motion.span
          key={i}
          className="w-[3px] rounded-full bg-[#1DB954]"
          animate={{ height: ["30%", "100%", "60%", "100%", "30%"] }}
          transition={{
            duration: 1.2,
            repeat: Infinity,
            delay: i * 0.15,
            ease: "easeInOut",
          }}
          style={{ display: "block" }}
        />
      ))}
    </span>
  );
}

export default function NowPlaying() {
  const [open, setOpen] = useState(false);
  const {
    isPlaying,
    title,
    artist,
    albumImageUrl,
    songUrl,
    duration,
    progress,
  } = useNowPlaying();

  const isActive = isPlaying && !!title;

  return (
    <div className="relative">
      <button
        onMouseEnter={() => isActive && setOpen(true)}
        onMouseLeave={() => setOpen(false)}
        className="flex items-center gap-2 rounded-full border border-border px-3 py-1.5 text-sm"
      >
        {/* Spotify SVG — no extra dep */}
        <svg
          viewBox="0 0 24 24"
          className={`h-3.5 w-3.5 ${isActive ? "fill-[#1DB954]" : "fill-muted-foreground"}`}
        >
          <path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
        </svg>

        {isActive ? (
          <>
            <span className="max-w-[100px] truncate font-mono text-xs">
              {title}
            </span>
            <SpotifyBars />
          </>
        ) : (
          <span className="font-mono text-xs text-muted-foreground">
            Not playing
          </span>
        )}
      </button>

      <AnimatePresence>
        {open && isActive && (
          <motion.div
            initial={{ opacity: 0, y: 8, scale: 0.97 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 8, scale: 0.97 }}
            transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
            onMouseEnter={() => setOpen(true)}
            onMouseLeave={() => setOpen(false)}
            className="absolute left-1/2 top-full z-50 mt-3 w-72 -translate-x-1/2 rounded-xl border border-border bg-card p-3 shadow-xl"
          >
            <div className="flex items-start gap-3">
              {/* Spinning album art */}
              <motion.div
                className="h-14 w-14 flex-shrink-0 overflow-hidden rounded-full"
                animate={{ rotate: 360 }}
                transition={{ duration: 6, repeat: Infinity, ease: "linear" }}
              >
                <Image
                  src={albumImageUrl}
                  alt={title}
                  width={56}
                  height={56}
                  className="h-full w-full object-cover"
                />
              </motion.div>

              <div className="min-w-0 flex-1">
                <p className="truncate text-sm font-medium">{title}</p>
                <p className="mt-0.5 truncate text-xs text-muted-foreground">
                  {artist}
                </p>
                <div className="mt-2.5 h-1 w-full overflow-hidden rounded-full bg-muted">
                  <div
                    className="h-full rounded-full bg-[#1DB954] transition-all duration-1000"
                    style={{ width: `${(progress / duration) * 100}%` }}
                  />
                </div>
              </div>

              <a
                href={songUrl}
                target="_blank"
                rel="noopener noreferrer"
                className="text-muted-foreground hover:text-foreground"
              >
                <ExternalLink size={14} />
              </a>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Details worth mentioning

The progress bar is an approximation. The API gives a snapshot — progress_ms at the time of the request. Between polls, elapsed time isn't tracked. For a portfolio widget this is fine; if you want frame-perfect accuracy you'd calculate elapsed time client-side with Date.now() between each SWR refresh.

The spinning album art. animate={{ rotate: 360 }} + repeat: Infinity + ease: "linear" — one of those details that makes people go "wait, it spins?". Stopping it on hover (whileHover={{ rotate: 0 }}) felt natural but I left that out of the simplified version above for readability.

Not playing state. When nothing's playing or the API returns a 204, the pill shows "Not playing" and becomes non-interactive. No loading spinners, no error states — just graceful degradation.

Why not use react-icons for the Spotify logo? Fewer dependencies. Inlining the SVG path is 3 lines and removes a package from the bundle.


Wrapping up

The full source — widget, hook, API route, and callback page — is live at stephenadeniji.com. Scroll to About to see it.

If you build your own version, let me know on Twitter