Blogs
28/03/2025
As a music enthusiast and developer, I wanted to share my current music taste with visitors to my personal website. In this blog post, I'll walk through how I built a real-time "Now Playing" Spotify integration for my Next.js website.
I wanted a subtle but engaging component that would:
The implementation required three main parts:
Let's break down each part of the solution.
The first challenge was authentication. Spotify's API uses OAuth 2.0, which typically requires user interaction. Since I wanted this to work server-side without user input, I implemented the "Client Credentials Flow" with a refresh token.
This is being done in SpotifyAPI.ts
file.
import queryString from "query-string";
const client_id = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID;
const client_secret = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_SECRET;
const refresh_token = process.env.NEXT_PUBLIC_SPOTIFY_REFRESH_TOKEN;
To avoid making unnecessary API calls, I implemented token caching:
// Cache structure to store the token and its expiration
let tokenCache = {
access_token: null,
expires_at: 0,
};
The getAccessToken
function checks if there's a valid cached token before requesting a new one:
const getAccessToken = async () => {
// Check if we have a cached token that's still valid
if (
tokenCache.access_token &&
tokenCache.expires_at &&
Date.now() < tokenCache.expires_at
) {
return { access_token: tokenCache.access_token };
}
// If no valid token in cache, request a new one
const concatenatedString = `${client_id}:${client_secret}`;
const basic = Buffer.from(concatenatedString).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: queryString.stringify({
grant_type: "refresh_token",
refresh_token,
}),
});
const data = await response.json();
// Cache the new token with expiration
// Spotify tokens typically expire in 1 hour (3600 seconds)
// We subtract 60 seconds as a buffer
tokenCache = {
access_token: data.access_token,
expires_at: Date.now() + (data.expires_in - 60) * 1000,
};
return { access_token: data.access_token };
};
Once authentication was sorted, I created a function to fetch the currently playing track:
export const getNowPlaying = async () => {
const { access_token } = await getAccessToken();
const response = await fetch(
"https://api.spotify.com/v1/me/player/currently-playing",
{
headers: {
Authorization: `Bearer ${access_token}`,
},
// Add cache control headers to prevent caching
cache: "no-store",
next: { revalidate: 0 },
}
);
return response;
};
The important details here:
cache: "no-store"
and revalidate: 0
to ensure we always get fresh dataI then created a helper function to parse the response:
export default async function getNowPlayingItem() {
const response = await getNowPlaying();
if (response.status === 204 || response.status > 400) {
return false;
}
const song = await response.json();
const artist = song.item.artists[0].name;
const isPlaying = song.is_playing;
const title = song.item.name;
return {
artist,
isPlaying,
title,
};
}
The React component needed to:
First, I used a Spotify Logo SVG component:
const SpotifyLogo = ({
width = 14,
height = 14,
color = "#a3a3a3",
}: {
width?: number;
height?: number;
color?: string;
}) => (
<svg width={width} height={height} x="0px" y="0px" viewBox="0 0 20 20">
<g>
<g>
<g>
<path
fill={color}
className="st0"
d="M10,0C4.5,0,0,4.5,0,10c0,5.5,4.5,10,10,10c5.5,0,10-4.5,10-10C20,4.5,15.5,0,10,0z M14.6,14.4
c-0.2,0.3-0.6,0.4-0.9,0.2c-2.3-1.4-5.3-1.8-8.8-1c-0.3,0.1-0.7-0.1-0.7-0.5c-0.1-0.3,0.1-0.7,0.5-0.7c3.8-0.9,7.1-0.5,9.7,1.1
C14.7,13.7,14.8,14.1,14.6,14.4z M15.8,11.7c-0.2,0.4-0.7,0.5-1.1,0.3C12,10.3,8,9.8,4.8,10.8c-0.4,0.1-0.8-0.1-1-0.5
c-0.1-0.4,0.1-0.8,0.5-1c3.6-1.1,8.1-0.6,11.2,1.3C15.9,10.9,16,11.3,15.8,11.7z M15.9,8.9C12.7,7,7.4,6.8,4.3,7.7
c-0.5,0.1-1-0.1-1.2-0.6C3,6.6,3.3,6.1,3.8,5.9c3.5-1.1,9.4-0.9,13.1,1.3c0.4,0.3,0.6,0.8,0.3,1.3C16.9,9,16.4,9.1,15.9,8.9z"
/>
</g>
</g>
</g>
</svg>
);
export default SpotifyLogo;
Here's how I structured the actual component:
This is being done in SpotifyStatus.tsx
file.
"use client";
import React, { useEffect, useState, useRef, ReactNode } from "react";
import SpotifyLogo from "./SpotifyLogo";
interface SpotifyData {
artist: string;
isPlaying: boolean;
title: string;
}
const SpotifyStatus: React.FC<{
initialData: SpotifyData | false,
fallback?: ReactNode
}> = ({
initialData,
fallback
}) => {
const [offline, setOffline] = useState<boolean>(!initialData);
const [result, setResult] = useState<SpotifyData>(
initialData || {
artist: "",
isPlaying: false,
title: "",
},
);
One of the more interesting challenges was handling text overflow. I wanted long track names to scroll horizontally when they didn't fit. This required:
const textRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [shouldScroll, setShouldScroll] = useState(false);
// Unified Effect for Resize and Overflow Handling
useEffect(() => {
const updateScrollBehavior = () => {
if (textRef.current && containerRef.current) {
const textWidth = textRef.current.scrollWidth;
const containerWidth = containerRef.current.clientWidth;
const isOverflowing = textWidth > containerWidth;
setShouldScroll(isOverflowing);
if (isOverflowing) {
const translateX = -(textWidth - containerWidth) - 15;
textRef.current.style.transform = `translateX(${translateX}px)`;
} else {
textRef.current.style.transform = "translateX(0)";
}
}
};
updateScrollBehavior();
window.addEventListener("resize", updateScrollBehavior);
return () => window.removeEventListener("resize", updateScrollBehavior);
}, [result]);
To keep the displayed track current, I implemented polling:
useEffect(() => {
const interval = setInterval(async () => {
try {
const response = await fetch("/api/spotify/now-playing");
const data = await response.json();
if (!data || data.error) {
setOffline(true);
} else {
setResult(data);
setOffline(false);
}
} catch (error) {
setOffline(true);
}
}, 15000);
return () => clearInterval(interval);
}, []);
The text is handled separately:
// Hide component if offline or not playing, show fallback
if (offline || !result.isPlaying) {
return fallback || null;
}
const scrollingText = `${result.title} by ${result.artist}`;
The component's render output is deliberately minimal but expressive:
return (
<div className="flex items-center gap-2">
<div>
<SpotifyLogo
width={16}
height={16}
color={"#25d865"}
/>
</div>
<div ref={containerRef} className="w-full inline-block overflow-hidden">
<div
ref={textRef}
className={`text-xs text-neutral-400 whitespace-nowrap ${
shouldScroll ? "marquee" : ""
}`}
>
<span>{scrollingText}</span>
</div>
</div>
</div>
);
};
Integrating the component into my Next.js footer was straightforward. I added a fallback though, that when no music is playing, it shows my name and year:
export default async function Footer() {
const initialData = await getNowPlayingItem();
//Other footer related things
return (
<footer className="flex flex-col justify-center m-4 gap-4 mt-32">
{/* Other components */}
<section className="text-neutral-400 text-xs">
<SpotifyStatus
initialData={initialData}
fallback={
<div>
({Data.version}) {Data.year}© {Data.firstName} {Data.lastName}.
</div>
}
/>
</section>
</footer>
);
}
The key here is using initialData
to provide server-rendered content before client-side JavaScript takes over.
In my globals.css
file, I added this animation:
.marquee {
animation: scroll-left 10s linear infinite alternate;
}
@keyframes scroll-left {
0% {
transform: translateX(0%);
}
100% {
transform: translateX(
calc(-100% + 100% / (scrollWidth / 100%))
); /* calculate the translation value based on the scrollWidth */
}
}
Here are the two states of the component:
Fig 1: Component when songs are playing
Fig 2: Component when no songs are playing
You can see it in action down below in the footer as well.
Spotify's API has rate limits, so I needed to ensure I wasn't making too many requests. I addressed this by:
Getting smooth scrolling animations working consistently across browsers was tricky. I found that using CSS variables for the animation distance worked best:
if (isOverflowing) {
const translateX = -(textWidth - containerWidth) - 15;
textRef.current.style.transform = `translateX(${translateX}px)`;
} else {
textRef.current.style.transform = "translateX(0)";
}
With Next.js, there's always the challenge of ensuring smooth hydration between server and client rendering. I solved this by:
There are several ways this component can be enhanced according to personal preferences:
Building this Spotify integration was a fun project that adds a personal touch to my website. The combination of Next.js server components for initial data fetching and client components for real-time updates works beautifully.
Feel free to adapt this approach for your own projects!
To implement this on your own site:
Happy coding and happy listening!