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 2026·8 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.

When I'm not listening to anything, it falls back to the last track I played, with a "played 12m ago" in the hover card. 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. When nothing's playing, the same route falls back to Spotify's "recently played" endpoint
  6. 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%20user-read-recently-played

The second scope, user-read-recently-played, is what powers the "last played" fallback. Skip it and that call returns a 403.

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();
}

async function getLastPlayed(accessToken: string) {
  const res = await fetch(
    "https://api.spotify.com/v1/me/player/recently-played?limit=1",
    {
      headers: { Authorization: `Bearer ${accessToken}` },
      next: { revalidate: 60 }, // changes rarely, so cache for a minute
    },
  );

  if (!res.ok) return null;

  const { items } = await res.json();
  const last = items?.[0];
  if (!last?.track) return null;

  return {
    title: last.track.name,
    artist: last.track.artists.map((a: { name: string }) => a.name).join(", "),
    albumImageUrl: last.track.album.images[0]?.url ?? "",
    songUrl: last.track.external_urls.spotify,
    playedAt: last.played_at,
  };
}

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, 400+ = error
    if (res.status !== 204 && res.status < 400) {
      const song = await res.json();

      if (song.is_playing && song.item) {
        return NextResponse.json({
          isPlaying: true,
          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,
        });
      }
    }

    // Nothing playing (or paused): fall back to the last track
    const lastPlayed = await getLastPlayed(access_token);
    return NextResponse.json({ isPlaying: false, lastPlayed });
  } catch {
    return NextResponse.json({ isPlaying: false, lastPlayed: null });
  }
}

If something's playing, you get the live track. Otherwise (a 204, a pause, or an error) the route returns { isPlaying: false, lastPlayed }, and lastPlayed is null if that second call fails too. The widget handles all three cases.


Step 4 — The SWR hook

pnpm add swr

Create hooks/useNowPlaying.ts:

import useSWR from "swr";

export interface LastPlayed {
  title: string;
  artist: string;
  albumImageUrl: string;
  songUrl: string;
  playedAt: string;
}

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,
    lastPlayed: (data?.lastPlayed ?? null) as LastPlayed | null,
  };
}

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 timeAgo(iso: string) {
  const mins = Math.max(
    0,
    Math.floor((Date.now() - new Date(iso).getTime()) / 60000),
  );
  if (mins < 1) return "just now";
  if (mins < 60) return `${mins}m ago`;
  const hours = Math.floor(mins / 60);
  if (hours < 24) return `${hours}h ago`;
  return `${Math.floor(hours / 24)}d ago`;
}

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,
    lastPlayed,
  } = useNowPlaying();

  const isActive = isPlaying && !!title;

  // The card shows the live track, or the last one played
  const track = isActive
    ? { title, artist, albumImageUrl, songUrl }
    : lastPlayed;

  return (
    <div className="relative">
      <button
        onMouseEnter={() => track && 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="flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
            {lastPlayed ? (
              <>
                last played
                <span className="max-w-[100px] truncate text-foreground/80">
                  {lastPlayed.title}
                </span>
              </>
            ) : (
              "Not playing"
            )}
          </span>
        )}
      </button>

      <AnimatePresence>
        {open && track && (
          <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: isActive ? 360 : 0 }}
                transition={{ duration: 6, repeat: Infinity, ease: "linear" }}
              >
                <Image
                  src={track.albumImageUrl}
                  alt={track.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">{track.title}</p>
                <p className="mt-0.5 truncate text-xs text-muted-foreground">
                  {track.artist}
                </p>
                {isActive ? (
                  <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>
                ) : (
                  <p className="mt-2.5 font-mono text-[10px] text-muted-foreground">
                    played {lastPlayed && timeAgo(lastPlayed.playedAt)}
                  </p>
                )}
              </div>

              <a
                href={track.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.

Offline state. When nothing's playing, the pill switches to a muted "last played" label and the hover card shows the previous track with a "played 12m ago" line instead of the progress bar. The album art stops spinning, since nothing's playing. If the recently-played call fails (usually a missing scope), it falls back to a plain "Not playing" and stays 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