spotify now playing.
Shows what I'm currently listening to on Spotify, updated while the page stays open. Built on Next.js API routes with SWR polling.
live component, try it
dependencies.
what to expect.
usage.
import NowPlaying from "@/components/ui/now-playing";
export default function MyPage() {
return (
<div className="flex items-center justify-center p-8">
<NowPlaying />
</div>
);
}the component.
"use client";
import useSWR from "swr";
import Image from "next/image";
import Spotify from "@/components/SVGs/platforms/Spotify";
import { NowPlayingSkeleton, NOW_PLAYING_RESERVE } from "@/components/ui";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export default function NowPlaying() {
const { data, isLoading } = useSWR("/api/spotify/now-playing", fetcher, {
refreshInterval: 30000, // Refresh every 30 seconds
revalidateOnFocus: false, // Don't refetch when tab gains focus
revalidateOnReconnect: true, // Refetch when network reconnects
refreshWhenHidden: false, // Only poll when tab is visible to save bandwidth
});
// Loading state with skeleton
if (isLoading) {
return <NowPlayingSkeleton />;
}
// No data or error state
if (!data || (!data.isPlaying && !data.title)) {
return (
<section className={`text-center ${NOW_PLAYING_RESERVE}`}>
<div className="widget-enter">
<div className="flex items-center justify-center gap-2 mb-3">
<Spotify className="text-green-500 text-lg" />
<h3 className="text-base sm:text-lg font-medium text-foreground/90">now listening.</h3>
</div>
<p className="text-sm text-muted-foreground">not listening to anything right now.</p>
</div>
</section>
);
}
const headerText = data.isRecent ? "last played." : "now listening.";
const ariaLabel = data.isRecent
? `Last played: ${data.title} by ${data.artist} on Spotify`
: `Currently listening to ${data.title} by ${data.artist} on Spotify`;
return (
<section className={`text-center ${NOW_PLAYING_RESERVE}`}>
<div className="widget-enter flex items-center justify-center gap-2 mb-3">
<Spotify className="text-green-500 text-xl" />
<h2 className="text-base sm:text-lg font-semibold text-foreground/90">{headerText}</h2>
</div>
<div className="flex flex-col items-center gap-3">
<div className="widget-enter [--enter-delay:70ms]">
<a
href={data.songUrl}
target="_blank"
rel="noopener noreferrer"
className="transition-transform hover:scale-105 active:scale-95 rounded-xl block ease-in-out duration-200"
aria-label={ariaLabel}
style={{ color: 'inherit' }}
>
<Image
src={data.albumImageUrl}
alt={data.title}
width={100}
height={100}
className="rounded-xl shadow-md"
/>
</a>
</div>
<div className="widget-enter [--enter-delay:140ms]">
<p className="text-sm font-medium text-foreground">{data.title}</p>
<p className="text-sm text-muted-foreground">{data.artist}</p>
</div>
</div>
</section>
);
}how it works.
Spotify credentials can't go anywhere near the browser, so the widget talks to a Next.js route handler instead. That route keeps the secrets, swaps a long-lived refresh token for a short-lived access token on every call, and hands back only the four fields the UI actually renders. The client polls it with SWR every 30 seconds. The route caches for the same 30 seconds, so a busy day costs you the same number of Spotify calls as a quiet one.
what it talks to.
accounts.spotify.com/api/tokenExchanges your refresh token for a fresh access token. Authenticated with a base64 client_id:client_secret Basic header.
api.spotify.com/v1/me/player/currently-playingThe live track. When nothing is playing you get back 204 with an empty body, so check the status before you try to parse it.
api.spotify.com/v1/me/player/recently-played?limit=1Fallback for the 204 case (and for ads or private sessions) so the widget shows a last-played track instead of going blank.
environment.
SPOTIFY_CLIENT_IDFrom your Spotify dashboard app.
SPOTIFY_CLIENT_SECRETSame page. Server-side only, so never prefix it with NEXT_PUBLIC.
SPOTIFY_REFRESH_TOKENProduced once by the callback step below.
SPOTIFY_REDIRECT_URIMust match the dashboard entry byte for byte.
setup.
Create a Spotify app
At developer.spotify.com/dashboard, create an app and copy the Client ID and Client Secret. Add a redirect URI while you are there (http://localhost:3000/api/spotify/callback is fine for local work) and save that same value as SPOTIFY_REDIRECT_URI.
Authorize your own account
Visit this URL in a browser and approve. The two scopes are what allow reading the live track and the recent history; without them the API returns 403.
https://accounts.spotify.com/authorize ?client_id=YOUR_CLIENT_ID &response_type=code &redirect_uri=http://localhost:3000/api/spotify/callback &scope=user-read-currently-playing user-read-recently-playedTrade the ?code for a refresh token
Spotify redirects back with a ?code that works once and expires within seconds. This route trades it in. Save the refresh_token it prints as SPOTIFY_REFRESH_TOKEN, because you will not be shown it again.
// app/api/spotify/callback/route.ts import { NextRequest, NextResponse } from "next/server"; export async function GET(req: NextRequest) { const code = req.nextUrl.searchParams.get("code"); if (!code) return NextResponse.json({ error: "No code" }, { status: 400 }); const basic = Buffer.from( `${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}` ).toString("base64"); const res = 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: "authorization_code", code, redirect_uri: process.env.SPOTIFY_REDIRECT_URI!, }), }); // refresh_token comes back here and nowhere else, so save it now. return NextResponse.json(await res.json()); }Add the now-playing route
This is the endpoint the widget polls. It refreshes the access token, asks for the current track, falls back to recently-played on a 204, and caches the result for 30 seconds so traffic spikes don't multiply into Spotify calls.
// app/api/spotify/now-playing/route.ts import { NextResponse } from "next/server"; const CACHE_TTL = 30_000; let cache: { data: unknown; timestamp: number } | null = null; async function getAccessToken() { const basic = Buffer.from( `${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}` ).toString("base64"); const res = 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!, }), }); if (!res.ok) throw new Error("Token refresh failed"); return (await res.json()).access_token as string; } export async function GET() { if (cache && Date.now() - cache.timestamp < CACHE_TTL) { return NextResponse.json(cache.data); } const token = await getAccessToken(); const auth = { Authorization: `Bearer ${token}` }; let res = await fetch( "https://api.spotify.com/v1/me/player/currently-playing", { headers: auth } ); // 204 = nothing playing. Fall back to the last track instead of a blank UI. let track = res.status === 204 ? null : (await res.json())?.item; let isPlaying = Boolean(track); if (!track) { res = await fetch( "https://api.spotify.com/v1/me/player/recently-played?limit=1", { headers: auth } ); track = (await res.json())?.items?.[0]?.track ?? null; } const data = track ? { isPlaying, title: track.name, artist: track.artists.map((a: { name: string }) => a.name).join(", "), albumImageUrl: track.album.images[0]?.url ?? "", songUrl: track.external_urls.spotify, } : { isPlaying: false }; cache = { data, timestamp: Date.now() }; return NextResponse.json(data, { headers: { "Cache-Control": "public, s-maxage=30, stale-while-revalidate=60" }, }); }Poll it from the client
SWR handles the interval and the deduping. Keep refreshInterval at or above the route's cache TTL, since polling faster just re-reads the same cached payload.
"use client"; import useSWR from "swr"; const fetcher = (url: string) => fetch(url).then((r) => r.json()); export default function NowPlaying() { const { data } = useSWR("/api/spotify/now-playing", fetcher, { refreshInterval: 30_000, revalidateOnFocus: false, }); if (!data?.title) return <p>not playing.</p>; return ( <a href={data.songUrl} target="_blank" rel="noopener noreferrer"> {data.title} · {data.artist} </a> ); }Expect to re-run this twice a year
Spotify refresh tokens stop working roughly six months after the original authorization, and the failure shows up as invalid_grant in your logs. There is no way to renew one programmatically, so when it happens just repeat steps 2 and 3 and update the env var.