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.
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:
- Spotify's API has a "currently playing" endpoint
- That endpoint requires an access token, which expires every hour
- We get a long-lived refresh token once (via OAuth) and use it to mint fresh access tokens on the server
- A Next.js API route handles the token refresh + data fetch on every request
- 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