Building a Spotify Now Playing Widget for Your Portfolio

One of the better touches you can add to a developer portfolio is a live Spotify widget — something that shows what you're currently listening to, or what you last played. It's small, personal, and it gives the site a bit of life. Here's exactly how I built mine.
The stack: Next.js App Router, TanStack Query on the frontend, and the Spotify Web API. No external library, no third-party service sitting in the middle. Just two files.

Step 1: Set up a Spotify app

Before writing any code, you need credentials. Go to the Spotify Developer Dashboard, create an app, and grab your Client ID and Client Secret. Set your redirect URI to http://localhost:3000/callback for now — you'll need it in the next step.
You need three environment variables by the end of this:
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REFRESH_TOKEN=
The first two come straight from your dashboard. The third one you have to earn.

Step 2: Get a refresh token (one-time setup)

Spotify uses OAuth 2.0. To read your own listening history, you need to authorize your app against your own account and capture the refresh token it gives back. You only do this once — the refresh token doesn't expire unless you revoke it.
First, construct this URL and open it in your browser (replace the client ID):
https://accounts.spotify.com/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:3000/callback&scope=user-read-currently-playing%20user-read-recently-played
You'll get redirected to your callback URL with a code param in the query string. Grab that code, then exchange it for tokens with a POST request:
curl -X POST https://accounts.spotify.com/api/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=YOUR_CODE" \
  -d "redirect_uri=http://localhost:3000/callback" \
  -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET"
The response gives you both an access_token (short-lived) and a refresh_token (long-lived). Copy the refresh token into your .env. You're done with this step forever.

Step 3: The API route

Create app/api/spotify/route.ts. This route does two things: exchanges your refresh token for a fresh access token on every request, then hits the Spotify API.
const getAccessToken = async () => {
   const res = await fetch("https://accounts.spotify.com/api/token", {
      method: "POST",
      headers: {
         "Content-Type": "application/x-www-form-urlencoded",
         Authorization: `Basic ${Buffer.from(
         `${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}`
         ).toString("base64")}`,
      },
      body: new URLSearchParams({
         grant_type: "refresh_token",
         refresh_token: process.env.SPOTIFY_REFRESH_TOKEN!,
      }),
   });
   return res.json();
};

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

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

   if (res.status !== 204 && res.status <= 400) {
      const data = await res.json();
      if (data.is_playing) {
         return Response.json({
         isPlaying: true,
         title: data.item.name,
         artist: data.item.artists.map((a: any) => a.name).join(", "),
         albumArt: data.item.album.images[0].url,
         songUrl: data.item.external_urls.spotify,
         });
      }
   }

   // Nothing playing — fall back to last played
   const recentRes = await fetch(
      "https://api.spotify.com/v1/me/player/recently-played?limit=1",
      { headers: { Authorization: `Bearer ${access_token}` } }
   );
  const recentData = await recentRes.json();
  const track = recentData.items[0].track;

   return Response.json({
      isPlaying: false,
      title: track.name,
      artist: track.artists.map((a: any) => a.name).join(", "),
      albumArt: track.album.images[0].url,
      songUrl: track.external_urls.spotify,
   });
}
A few things worth understanding here.
The currently-playing endpoint returns a 204 when nothing is playing — no body, just an empty response. That's why the condition checks res.status !== 204 before trying to parse JSON. If you skip that check and try to call .json() on a 204, you'll get a parse error and your whole route crashes.
The is_playing check inside the response matters too. Spotify can return a 200 with is_playing: false if something is paused but the player is still active. In that case, we fall through to recently played anyway, so the widget always shows something.
The credential exchange uses HTTP Basic Auth — Client ID and Secret joined with a colon, base64-encoded, sent in the Authorization header. That's the Spotify token endpoint's expected format.

Step 4: The component

Create your NowPlaying component. It uses TanStack Query to poll the API route every 30 seconds and handles four distinct states: loading, error, no data, and the actual track.
"use client";

import { FaSpotify } from "react-icons/fa";
import Image from "next/image";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";

type NowPlaying = {
  isPlaying: boolean;
  title?: string;
  artist?: string;
  albumArt?: string;
  songUrl?: string;
};

export function NowPlaying() {
  const { data, isError, isLoading } = useQuery<NowPlaying>({
    queryKey: ["spotify"],
    queryFn: () => fetch("/api/spotify").then((r) => r.json()),
    refetchInterval: 30000,
  });

  if (isLoading) {
    return <NowPlayingShell shimmer />;
  }

  if (isError || !data) {
    return <NowPlayingShell />;
  }

  return (
    <div className="bg-secondary border border-border/40 rounded-2xl p-1.5">
      <div className="bg-accent border border-border/20 p-1.5 rounded-xl">
        <div className="flex gap-4">
          <div className="size-16 rounded-sm overflow-hidden shrink-0">
            {data.albumArt && (
              <Image
                src={data.albumArt}
                alt="album art"
                width={80}
                height={80}
                className="object-cover"
              />
            )}
          </div>
          <div className="flex flex-col justify-center">
            <span className="font-medium leading-snug line-clamp-1">
              {data.title}
            </span>
            <span className="text-muted-foreground leading-snug line-clamp-1">
              {data.artist}
            </span>
          </div>
        </div>
      </div>

      <div className="flex justify-between pt-2.5 pl-1 pr-0.5 text-xs text-muted-foreground font-medium">
        <span>{data.isPlaying ? "Currently playing" : "Last played"}</span>
        <Link
          href={data.songUrl ?? "https://spotify.com"}
          target="_blank"
          className="flex gap-1.5 items-center hover:text-foreground transition-colors"
        >
          <span>Listen on Spotify</span>
          <FaSpotify className="size-4 pb-0.5 text-green-500" />
        </Link>
      </div>
    </div>
  );
}
The refetchInterval: 30000 is the polling mechanism — TanStack Query will silently re-hit /api/spotify every 30 seconds while the component is mounted, keeping the displayed track reasonably fresh without hammering the API.
The line-clamp-1 on the track and artist names is important — some track titles and artist strings (especially when you join multiple artists with commas) are long enough to break the layout. Clamping to one line keeps the card dimensions fixed regardless of what's playing.

The loading state

The shimmer skeleton is worth doing properly. Rather than a spinner or blank space, you want a placeholder that matches the exact dimensions of the real widget — same card structure, same proportions, skeleton bones where the album art and text will be. When the data loads, the layout doesn't shift, it just fills in.
function NowPlayingShell({ shimmer }: { shimmer?: boolean }) {
   return (
      <div className={`bg-secondary border border-border/40 rounded-2xl p-1.5 ${shimmer ? "animate-shimmer" : ""}`}>
         <div className="bg-accent border border-border/20 p-1.5 rounded-xl">
         <div className="flex gap-4">
            <div className="size-16 rounded-sm bg-muted shrink-0 flex items-center justify-center">
               {!shimmer && <FaSpotify className="size-6 text-muted-foreground/50" />}
            </div>
            <div className="flex flex-col justify-center gap-2 w-full">
               <div className="h-4 w-3/4 bg-muted rounded" />
               <div className="h-3 w-1/2 bg-muted rounded" />
            </div>
         </div>
         </div>
         <div className="flex justify-between pt-2.5 pl-1 pr-0.5">
         <div className="h-3 w-24 bg-muted rounded" />
         <div className="h-3 w-32 bg-muted rounded" />
         </div>
      </div>
   );
}

A note on the token exchange

You might wonder why the route exchanges the refresh token on every single request instead of caching the access token somewhere. The clean answer is: for a portfolio site, it doesn't matter. The token exchange adds maybe 100–200ms and your polling interval is 30 seconds. Nobody notices.
If you were doing this at any real scale, you'd cache the access token in Redis with a TTL just under 3600 seconds (Spotify access tokens last an hour) and only call the token endpoint when it's expired. But for a personal site hitting this endpoint maybe a few hundred times a day, the simpler approach is fine.

That's it

Two files, roughly 80 lines of meaningful code between them. The result is a widget that always shows something — either what's playing right now, or the last track you had on — and updates itself quietly in the background while anyone's looking at your portfolio.
The isPlaying flag in the response lets you toggle the label between "Currently playing" and "Last played" without any additional logic, which is a small touch that makes the widget feel more alive than if it just always said the same thing.