Spotify Now Playing Widget is a dynamic widget that displays your currently playing Spotify track in real-time on your website or portfolio. Building this feature was one of my favorite portfolio additions.
This widget combines the Spotify Web API, Next.js API routes, and real-time data fetching to create an engaging music widget that updates automatically. What started as a simple “what am I listening to?” became a sophisticated system that handles authentication, caching, error states, and provides a seamless user experience.
The implementation uses SWR for efficient data fetching with intelligent caching and React components for a responsive UI that gracefully adapts to different playback states, whether I’m jamming to music, in a private session, or not listening to anything at all.
Visual Preview: See It In Action
Before diving into the technical details, let me show you what this actually looks like in practice. These screenshots are from my actual portfolio - not mockups, but the real widget in different states.
When Music is Playing
Displaying “I Really Want to Stay at Your House” by Rosa Walton. Shows the album art, track title and artist name indicating it’s live data. The album artwork is clickable and takes you directly to the song on Spotify.
When Nothing is Playing
The graceful fallback state when I’m not listening to anything. It maintains the widget’s presence without looking broken or empty.
Why Build This?
As a developer who codes with music constantly playing, I wanted my portfolio to reflect this aspect of my personality. Instead of a static “about me” section, visitors can see exactly what’s soundtrack-ing my current coding session. It’s a small detail that adds personality and creates an instant connection with fellow music lovers.
The technical challenge was equally appealing: building a real-time widget that’s both performant and respectful of API limits, while handling all the edge cases that come with external API dependencies.
Architecture Overview
The system consists of three main components that work together seamlessly:
1. Backend API Route (/api/spotify/now-playing/route.ts
)
The heart of the system - a secure Next.js API route that handles all Spotify communication:
- OAuth 2.0 refresh token flow for continuous access without user intervention
- Server-side caching (30-second TTL) to minimize API calls and respect rate limits
- Comprehensive error handling for network issues, private sessions, and API limitations
- TypeScript interfaces for complete type safety across the entire data flow
2. Frontend Component (components/ui/now-playing.tsx
)
A polished React component that provides the user interface:
- SWR integration for automatic polling, caching, and background updates
- Responsive design with album artwork and click-through functionality
- Multiple UI states - playing, not playing, loading, and error states
- Accessibility features with proper ARIA labels and keyboard navigation
3. Real-time Synchronization
Smart polling system that keeps everything current without being wasteful:
- 30-second refresh interval - frequent enough to feel real-time, respectful of API limits
- Visibility-aware polling - only updates when the browser tab is active
- Automatic reconnection handling for network interruptions
- Background sync when users return to the tab
Getting Started: Setting Up Spotify Integration
The setup process involves configuring a Spotify app and handling OAuth authentication. Don’t worry; it’s more straightforward than it initially appears!
Step 1: Create Your Spotify Application
Navigate to the Spotify Developer Dashboard and create a new app:
App Configuration:
- App Name: Something descriptive like “Portfolio Now Playing” or “Personal Website Music Widget”
- App Description: Brief description like “Displays currently playing music on my portfolio website”
- Website: Your portfolio URL (optional but recommended)
- Redirect URI: (in case Spotify has issues with localhost in URI; use ngrok
ngrok http 3000
)- Development:
http://localhost:3000/api/spotify/callback
- Production:
https://yourdomain.com/api/spotify/callback
- Development:
- Select Web API from the list in Which API/SDKs are you planning to use?.
- Save these changes.
Important: The redirect URI must match exactly what you’ll use in the OAuth flow, including the protocol (http
vs https
).
Step 2: Configure Environment Variables
Create a .env.local
file in your project root with your app credentials:
# Spotify App Credentials (from your Spotify Developer Dashboard)
SPOTIFY_CLIENT_ID=your_client_id_here
SPOTIFY_CLIENT_SECRET=your_client_secret_here
# This will be generated in Step 3
SPOTIFY_REFRESH_TOKEN=your_refresh_token_here
Step 3: OAuth Authentication Flow
This is the trickiest part, but you only need to do it once. We need to get a refresh token that allows our app to access your Spotify data continuously.
I’ll show you two methods: an easier method using a temporary callback route in your app, and a manual method using curl commands.
Method 1: Using a Callback Route (Easier)
This method creates a temporary API route that handles the token exchange for you automatically.
3a. Create the Callback Route
Create a file at app/api/spotify/callback/route.ts
:
import { NextRequest, NextResponse } from "next/server";
const TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";
export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get("code");
if (!code) {
return NextResponse.json({ error: "No code found" }, { status: 400 });
}
const basic = Buffer.from(
`${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}`
).toString("base64");
const res = await fetch(TOKEN_ENDPOINT, {
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!, // should match ngrok/localhost
}),
});
const data = await res.json();
if (data.error) {
return NextResponse.json({ error: data.error }, { status: 400 });
}
// Only shown once, grab this refresh_token and save to .env
return NextResponse.json({
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_in: data.expires_in,
});
}
3b. Add Redirect URI to Environment Variables
Update your .env.local
to include:
SPOTIFY_REDIRECT_URI=http://localhost:3000/api/spotify/callback
# Or if using ngrok: https://your-ngrok-url.ngrok.io/api/spotify/callback
💡 Using ngrok (Optional): If Spotify has issues with localhost, you can use ngrok:
ngrok http 3000
Then use the ngrok URL as your redirect URI (e.g., https://abc123.ngrok.io/api/spotify/callback
)
3c. Build the Authorization URL
Replace YOUR_CLIENT_ID
with your actual client ID:
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
3d. Authorize and Get Your Token
- Start your Next.js development server:
npm run dev
- Visit the authorization URL in your browser
- Log in to Spotify and authorize your app
- You’ll be redirected to your callback route, which displays your tokens as JSON
- Copy the
refresh_token
value from the JSON response - Add it to your
.env.local
file asSPOTIFY_REFRESH_TOKEN
3e. Clean Up (Important!)
Once you have your refresh token, delete the callback route file (app/api/spotify/callback/route.ts
). You don’t need it anymore and it’s a security risk to leave it in production.
Method 2: Manual Token Exchange (Alternative)
If you prefer not to create a callback route, you can use curl commands directly.
3a. Build the Authorization URL
Replace YOUR_CLIENT_ID
with your actual client ID:
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
3b. Authorize Your App
- Visit the URL in your browser
- Log in to Spotify and authorize your app
- You’ll be redirected to a broken page (that’s okay!)
- Copy the entire code from the URL (everything after
code=
)
3c. Exchange Authorization Code for Refresh Token Use this curl command (replace the placeholders with your actual values):
curl -X POST https://accounts.spotify.com/api/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Authorization: Basic $(echo -n 'CLIENT_ID:CLIENT_SECRET' | base64)" \
-d "grant_type=authorization_code&code=YOUR_AUTH_CODE&redirect_uri=http://localhost:3000/api/spotify/callback"
Windows PowerShell version:
$credentials = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("CLIENT_ID:CLIENT_SECRET"))
Invoke-RestMethod -Uri "https://accounts.spotify.com/api/token" -Method POST -Headers @{Authorization="Basic $credentials"; "Content-Type"="application/x-www-form-urlencoded"} -Body "grant_type=authorization_code&code=YOUR_AUTH_CODE&redirect_uri=http://localhost:3000/api/spotify/callback"
3d. Save Your Refresh Token
The response will include a refresh_token
. Add this to your .env.local
file as SPOTIFY_REFRESH_TOKEN
.
Pro Tip: The refresh token doesn’t expire (unless you revoke app access), so you only need to do this OAuth dance once!
Implementation: The Code That Powers It All
Backend API Route (/api/spotify/now-playing/route.ts
)
This is where the magic happens - a robust API endpoint that handles Spotify authentication, caching, and error management:
import { NextResponse } from 'next/server';
// Interfaces for Spotify API responses
interface SpotifyTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
interface SpotifyArtist {
name: string;
}
interface SpotifyAlbumImage {
url: string;
height: number;
width: number;
}
interface SpotifyAlbum {
images: SpotifyAlbumImage[];
}
interface SpotifyTrack {
name: string;
artists: SpotifyArtist[];
album: SpotifyAlbum;
external_urls: {
spotify: string;
};
}
interface SpotifyCurrentlyPlayingResponse {
is_playing: boolean;
item: SpotifyTrack | null;
}
interface CachedData {
isPlaying: boolean;
title?: string;
artist?: string;
albumImageUrl?: string;
songUrl?: string;
error?: string;
}
// Server-side cache to reduce Spotify API calls
const CACHE_TTL = 30000; // 30 seconds
let cache: { data: CachedData; timestamp: number } | null = null;
async function getAccessToken(refreshToken: string): Promise<SpotifyTokenResponse> {
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: refreshToken,
}),
});
if (!res.ok) {
const error = await res.text();
console.error('Failed to get access token:', error);
throw new Error('Failed to get access token');
}
return res.json() as Promise<SpotifyTokenResponse>;
}
export async function GET() {
try {
// Check cache first to reduce Spotify API calls
if (cache && Date.now() - cache.timestamp < CACHE_TTL) {
return NextResponse.json(cache.data);
}
const refreshToken = process.env.SPOTIFY_REFRESH_TOKEN;
// Check for missing environment variables
if (!refreshToken) {
console.error('SPOTIFY_REFRESH_TOKEN is missing');
return NextResponse.json({ isPlaying: false, error: 'Missing refresh token' });
}
if (!process.env.SPOTIFY_CLIENT_ID || !process.env.SPOTIFY_CLIENT_SECRET) {
console.error('Missing Spotify client credentials');
return NextResponse.json({ isPlaying: false, error: 'Missing credentials' });
}
// Get access token
const { access_token } = await getAccessToken(refreshToken);
// Fetch currently playing
const resp = await fetch('https://api.spotify.com/v1/me/player/currently-playing', {
headers: { Authorization: `Bearer ${access_token}` },
});
// Handle no content (not playing anything)
if (resp.status === 204) {
const responseData = { isPlaying: false };
cache = { data: responseData, timestamp: Date.now() };
return NextResponse.json(responseData);
}
// Handle errors
if (!resp.ok) {
const error = await resp.text();
console.error('Spotify API error:', resp.status, error);
return NextResponse.json({ isPlaying: false, error: `API error: ${resp.status}` });
}
const data = await resp.json() as SpotifyCurrentlyPlayingResponse;
// Handle private session or ad playing
if (!data || !data.item) {
const responseData: CachedData = { isPlaying: false };
cache = { data: responseData, timestamp: Date.now() };
return NextResponse.json(responseData);
}
const responseData: CachedData = {
isPlaying: data.is_playing,
title: data.item.name,
artist: data.item.artists.map((a: SpotifyArtist) => a.name).join(', '),
albumImageUrl: data.item.album.images[0]?.url || '',
songUrl: data.item.external_urls.spotify,
};
// Store in cache before returning
cache = { data: responseData, timestamp: Date.now() };
return NextResponse.json(responseData);
} catch (error) {
console.error('Error in Spotify API route:', error);
return NextResponse.json({
isPlaying: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
Key Features of the API Route:
- Smart Caching: 30-second server-side cache reduces API calls to just 2/hour
- Automatic Token Refresh: Handles OAuth token renewal transparently
- Comprehensive Error Handling: Gracefully manages private sessions, ads, network issues, and missing data
- Type Safety: Full TypeScript interfaces ensure reliable data structures
Frontend Component (components/ui/now-playing.tsx
)
A polished React component that brings the data to life:
"use client";
import useSWR from "swr";
import Image from "next/image";
import { FaSpotify } from "react-icons/fa";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export default function NowPlaying() {
// SWR configuration for optimal performance
const { data } = useSWR("/api/spotify/now-playing", fetcher, {
refreshInterval: 30000, // 30 seconds - sync with server cache
revalidateOnFocus: false, // Don't refetch when tab gains focus
revalidateOnReconnect: true, // Refetch when network reconnects
refreshWhenHidden: false, // Only poll when tab is visible
});
// No data or error state - clean, informative fallback
if (!data || (!data.isPlaying && !data.title)) {
return (
<section className="text-center">
<div className="flex items-center justify-center gap-2 mb-3">
<FaSpotify className="text-green-500 text-lg" />
<h3 className="text-base font-medium text-foreground/90">now listening.</h3>
</div>
<p className="text-sm text-muted-foreground">not listening to anything right now.</p>
</section>
);
}
// Active listening state - rich, interactive display
return (
<section className="text-center">
<div className="flex items-center justify-center gap-2 mb-3">
<FaSpotify className="text-green-500 text-xl" />
<h2 className="text-lg font-semibold text-foreground/90">now listening.</h2>
</div>
<div className="flex flex-col items-center gap-3">
<a
href={data.songUrl}
target="_blank"
rel="noopener noreferrer"
className="transition-transform hover:scale-105 focus:scale-105 focus:outline-none focus:ring-2 focus:ring-primary rounded-xl"
aria-label={`Listen to ${data.title} by ${data.artist} on Spotify`}
>
<Image
src={data.albumImageUrl}
alt={data.title}
width={100}
height={100}
className="rounded-xl shadow-md"
/>
</a>
<div>
<p className="text-sm font-medium text-foreground">{data.title}</p>
<p className="text-sm text-muted-foreground">{data.artist}</p>
</div>
</div>
</section>
);
}
Component Highlights:
- SWR Integration: Automatic background updates with intelligent caching
- Accessibility First: Proper ARIA labels and keyboard navigation
- Performance Optimized: Only polls when tab is visible, reducing bandwidth usage
- Clean States: Elegant handling of both “playing” and “not playing” scenarios
- Interactive Elements: Clickable album art that opens the song in Spotify
Data Flow Architecture
The system follows a clean, predictable flow:
- Component Initialization: SWR makes the first API call when the component mounts
- Cache Check: API route checks if cached data is still fresh (< 30 seconds old)
- Token Management: If cache is stale, refresh Spotify access token using stored refresh token
- Spotify API Call: Fetch current playback state from Spotify’s real-time endpoint
- Data Processing: Transform raw Spotify response into clean, typed data structure
- Response & Caching: Return formatted data and update server-side cache
- UI Rendering: Component displays current track with album art and metadata
- Background Polling: SWR automatically repeats this flow every 30 seconds
This architecture ensures the UI always feels responsive while being respectful of API rate limits and user bandwidth.
Technical Deep Dive: What Makes This Work
Performance & Efficiency
Multi-Layer Caching Strategy:
- Server-side Cache: 30-second TTL reduces Spotify API calls from ~120/hour to just 2/hour
- SWR Client Cache: Browser-level caching with smart background revalidation
- Visibility-Aware Polling: Only updates when the browser tab is active, saving bandwidth
- Network Resilience: Automatic reconnection and graceful degradation
Real-World Impact: In production, this setup typically makes only 48 Spotify API calls per day instead of 2,880+ calls without caching - a 98% reduction!
Bulletproof Error Handling
Building a real-time music widget means handling every possible failure mode gracefully:
Spotify API Edge Cases:
- Status 204: No content (nothing currently playing)
- Status 401: Token expired (handled by automatic refresh)
- Private Sessions: Spotify returns null data when user is in private mode
- Ad Playback: Advertisements don’t return track information
- Network Timeouts: Graceful fallback to “not listening” state
Configuration Errors:
- Missing environment variables trigger clear error messages
- Invalid credentials are caught and logged for easy debugging
- Malformed refresh tokens are handled without crashing
User Experience Excellence
Seamless State Transitions:
- Loading State: Clean placeholder while data loads
- Playing State: Rich display with album art, track info, and Spotify link
- Not Playing State: Friendly message that maintains the widget’s presence
- Error State: Graceful degradation that doesn’t break the page layout
Accessibility Features:
- ARIA Labels: Screen reader-friendly descriptions for all interactive elements
- Keyboard Navigation: Full keyboard accessibility with proper focus management
- Color Contrast: Text colors meet WCAG accessibility standards
- Alternative Text: Meaningful alt text for album artwork
Security & Privacy
Zero Client-Side Secrets:
- All Spotify credentials remain server-side only
- Refresh tokens never travel to the browser
- API keys are properly scoped with minimal required permissions
Data Privacy:
- Only accesses currently playing track (not music history or preferences)
- No user data is stored beyond the 30-second cache
- Respects Spotify’s private session feature
Production Considerations
Monitoring & Debugging:
- Comprehensive error logging for troubleshooting
- Console warnings for configuration issues
- Server-side error tracking for API failures
Deployment Notes:
- Works seamlessly with Vercel, Netlify, and other JAMstack platforms
- Environment variables must be configured in your deployment platform
- No additional infrastructure required (uses server-side caching, not external cache)
Dependencies & Installation
Required Packages
{
"swr": "^2.3.6", // Smart data fetching with caching
"next": "^15.5.4", // React framework with API routes
"react": "^19.0.0", // UI library
"react-icons": "^5.5.0" // Spotify icon component
}
Installation Commands
# Install core dependencies
npm install swr next react react-icons
# Or with yarn
yarn add swr next react react-icons
# If using TypeScript (recommended)
npm install -D typescript @types/react @types/node
Real-World Results
After implementing this on my portfolio, I’ve noticed:
- Visitor Engagement: People spend more time exploring when they see I’m listening to music they recognize
- Conversation Starters: The music widget has led to genuine connections with potential collaborators and employers
- Performance: Zero impact on page load times thanks to the smart caching strategy
- Reliability: 99.9% uptime with graceful handling of Spotify API outages
The feature adds personality without sacrificing performance, exactly what a developer portfolio should do.
The Final Product: Widget in Context
Here’s how the Spotify widget looks integrated into my actual portfolio. Context matters - it’s not just a standalone component, but part of a cohesive design that tells visitors who I am.
It sits right next to my bio and social links near my gear. section, making it easy for visitors to see what’s playing without it being too in-your-face.
Tips for Your Implementation
After living with this feature for a while, here are some lessons learned:
Visual Design Tips
-
Album Art Size: I settled on 100x100px as the sweet spot - large enough to be visually appealing, small enough not to dominate the page. Experiment with what works for your layout.
-
Spotify Branding: Keep the green (#1DB954) for the Spotify icon; it’s instantly recognizable and adds that pop of color.
-
Text Hierarchy: Notice how the track title is slightly bolder than the artist name. These small typographic details matter.
-
Spacing: Don’t cramp the component. Give it breathing room in your layout.
Technical Tips
-
Test All States: Before deploying, make sure to actually test all states - play music, pause it, go private, disconnect internet. You want to see every possible state.
-
Monitor API Calls: Check your Spotify Developer Dashboard to see your actual API usage. You should see very low numbers thanks to the caching.
-
Error Logging: Keep an eye on your server logs initially. You’ll quickly spot any configuration issues.
-
Refresh Token Security: Never commit your
.env.local
file. Add it to.gitignore
immediately.
Content Tips
-
Music Choice Matters: Remember, this widget broadcasts your music taste. Maybe skip the embarrassing songs, or embrace them fully - your choice!
-
Private Sessions: Use Spotify’s private session feature when you’re listening to focus music on repeat for the 50th time.
What’s Next?
Once you have the basic implementation working, consider these enhancements:
- Recently Played: Add a section showing your recently played tracks when nothing is currently playing
- Top Tracks: Integrate the Top Tracks API to showcase your favorite songs
- Listening History: Build a “music journal” showing what you were listening to while building different projects
- Genre Visualization: Create a visualization of your listening habits over time
- Collaborative Playlists: Share playlists for different coding moods (focus, debugging, celebration)
The Spotify API has tons of endpoints to explore. This Now Playing widget is just the beginning.