Spotify "Now Playing" Widget

Ever wanted to show what you're currently listening to on your personal website? In this comprehensive guide, we'll build a beautiful, production-ready Spotify "Now Playing" widget that displays your current track with real-time updates, elegant fallbacks, and robust error handling.

Building a Production-Ready Spotify "Now Playing" Widget with Next.js Server Components

Spotify Widget Demo

What We'll Build

By the end of this tutorial, you'll have:

  • A real-time Spotify widget showing your currently playing track using Next.js Server Components
  • Elegant fallback to recently played when nothing is active
  • Server-side data fetching with automatic caching
  • Comprehensive error handling for production reliability
  • Beautiful, responsive UI with smooth animations
  • Complete TypeScript implementation with service layer architecture

Prerequisites

  • Basic Next.js and React knowledge
  • Understanding of Server Components and App Router
  • Familiarity with TypeScript (recommended)
  • A Spotify account

Understanding Spotify Web API & Authorization

Spotify Web API Overview

The Spotify Web API provides access to a wealth of music data, including:

  • Currently playing tracks
  • Recently played history
  • User playlists and library
  • Track and artist information
  • Playback control (with proper scopes)

For our widget, we'll primarily use two endpoints:

  • /me/player/currently-playing - Gets the user's currently playing track
  • /me/player/recently-played - Gets recently played tracks as fallback

OAuth 2.0 Flow Selection

Spotify implements OAuth 2.0 with several flow options:

FlowAccess User ResourcesRequires Secret KeyToken Refresh
Authorization Code
Authorization Code with PKCE
Client Credentials
Implicit Grant

For our server-side Next.js application, we'll use the Authorization Code flow because:

  • We can securely store the client secret server-side
  • We get refresh tokens for long-term access
  • It's the most secure option for server applications

Setting Up Your Spotify App

1. Create a Spotify Developer Account

2. Create a New App

  • Click "Create App"
  • Fill in app name and description
  • Set redirect URI to http://localhost:3000/callback (for development)

3. Note Your Credentials

  • Copy your Client ID and Client Secret
  • We'll need these for authentication

4. Required Scopes

  • user-read-currently-playing - Access currently playing track
  • user-read-recently-played - Access listening history

Getting Your Refresh Token

To get a refresh token, you'll need to go through the authorization flow once. This is a one-time setup process:

// 1. Build authorization URL // This URL will redirect users to Spotify's authorization page const authUrl = `https://accounts.spotify.com/authorize?${new URLSearchParams({ response_type: 'code', // We want an authorization code (not a token directly) client_id: 'your_client_id', // Your app's Client ID from Spotify Dashboard scope: 'user-read-currently-playing user-read-recently-played', // Permissions we need redirect_uri: 'http://localhost:3000/callback', // Where Spotify sends the user back }).toString()}`; // 2. Visit the URL, authorize, and get the code from callback // User will see Spotify's permission screen and approve your app // Spotify redirects back with a 'code' parameter in the URL // 3. Exchange authorization code for access and refresh tokens const tokenResponse = await fetch('https://accounts.spotify.com/api/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', // Basic auth with client credentials (base64 encoded) 'Authorization': `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString('base64')}` }, body: new URLSearchParams({ grant_type: 'authorization_code', // OAuth flow type code: 'authorization_code_from_callback', // Code from step 2 redirect_uri: 'http://localhost:3000/callback', // Must match the one above }), }); // Response will contain: // - access_token: Short-lived token for API calls // - refresh_token: Long-lived token to get new access tokens // - expires_in: How long access_token is valid (usually 3600 seconds)

Important: Save the refresh_token from this response - you'll use it in your environment variables. The refresh token doesn't expire and allows your app to get new access tokens automatically.


Service Layer Architecture

Project Structure

Let's set up our project structure with a clean service layer:

src/
├── lib/
│   └── services/
│       └── spotify.ts            # Spotify service layer
├── app/
│   ├── api/
│   │   └── spotify/
│   │       └── route.ts          # API route (backward compatibility)
│   └── components/
│       └── SpotifyWidget.tsx     # Server Component
└── components/
    └── Bento/
        └── BentoItemNowPlaying/
            └── BentoItemNowPlaying.tsx  # Widget component

Environment Variables

Create a .env.local file in your project root with your Spotify credentials:

# Your app's public identifier from Spotify Dashboard SPOTIFY_CLIENT_ID=your_client_id_here # Your app's secret key - NEVER expose this to the client side SPOTIFY_CLIENT_SECRET=your_client_secret_here # The long-lived token you got from the authorization flow # This allows your app to get new access tokens automatically SPOTIFY_REFRESH_TOKEN=your_refresh_token_here

Security Note: The .env.local file should never be committed to version control. Add it to your .gitignore file. In production, set these as environment variables in your hosting platform (Vercel, Netlify, etc.).


Service Layer Implementation

Core Spotify Service

Create the Spotify service in src/lib/services/spotify.ts:

// Import environment variables - these are only available server-side const { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REFRESH_TOKEN } = process.env; // Base URL for Spotify's player endpoints const BASE_URL = "https://api.spotify.com/v1/me/player"; // Define the shape of data our widget will use // This is a simplified version of Spotify's complex API response export type SpotifyData = { isPlaying: boolean; // Whether music is currently playing songUrl: string | null; // Link to open track in Spotify (null if unavailable) title: string; // Track name albumImageUrl: string | null; // Album artwork URL (null if unavailable) artist: string; // Artist name(s), comma-separated if multiple }; // Simple in-memory cache to reduce API calls // In production, you might use Redis or another cache store interface CacheEntry { data: SpotifyData; // The cached Spotify data timestamp: number; // When this data was cached (milliseconds) } let cache: CacheEntry | null = null; // Our cache storage const CACHE_DURATION = 15000; // Cache for 15 seconds (15,000 ms)

Why service layer matters: By creating a dedicated service layer, we separate data fetching logic from presentation logic. This makes our code more maintainable, testable, and allows us to use the same data fetching logic in both Server Components and API routes.

TypeScript Types

Define comprehensive types matching Spotify's API response:

// Spotify returns multiple image sizes for album artwork type SpotifyImage = { url: string; // Direct URL to the image height: number; // Image height in pixels width: number; // Image width in pixels }; // Artist information from Spotify type SpotifyArtist = { external_urls: { spotify: string }; // Link to artist's Spotify page href: string; // API endpoint for this artist id: string; // Spotify's unique artist ID name: string; // Artist's display name type: "artist"; // Always "artist" for artist objects uri: string; // Spotify URI (spotify:artist:...) }; // Album information with nested artist data type SpotifyAlbum = { album_type: string; // "album", "single", "compilation" artists: SpotifyArtist[]; // Array of artists (albums can have multiple) external_urls: { spotify: string }; // Link to album's Spotify page href: string; // API endpoint for this album id: string; // Spotify's unique album ID images: SpotifyImage[]; // Array of album artwork in different sizes name: string; // Album title release_date: string; // Release date (YYYY-MM-DD format) type: "album"; // Always "album" for album objects uri: string; // Spotify URI (spotify:album:...) }; // Complete track information - this is what we get from the API type Track = { album: SpotifyAlbum; // Full album information artists: SpotifyArtist[]; // Array of track artists available_markets: string[]; // Countries where track is available disc_number: number; // Disc number (for multi-disc albums) duration_ms: number; // Track length in milliseconds explicit: boolean; // Whether track has explicit content external_ids: { // External identifiers (ISRC, UPC, etc.) isrc?: string; // International Standard Recording Code ean?: string; // European Article Number upc?: string; // Universal Product Code }; external_urls: { spotify: string }; // Link to track's Spotify page href: string; // API endpoint for this track id: string; // Spotify's unique track ID is_playable: boolean; // Whether track can be played name: string; // Track title popularity: number; // Popularity score (0-100) preview_url: string | null; // 30-second preview URL (may be null) track_number: number; // Track number on the album type: "track"; // Always "track" for track objects uri: string; // Spotify URI (spotify:track:...) is_local: boolean; // Whether this is a local file };

Token Management

Implement secure token refresh logic:

// Spotify's token response structure type AccessToken = { access_token: string }; const getAccessToken = async (): Promise<AccessToken> => { // Get credentials from environment variables const clientId = SPOTIFY_CLIENT_ID; const clientSecret = SPOTIFY_CLIENT_SECRET; const refreshToken = SPOTIFY_REFRESH_TOKEN; // Validate that all required credentials are present if (!clientId || !clientSecret || !refreshToken) { throw new Error("Missing Spotify credentials in environment variables"); } // Create Basic Auth header by base64 encoding "clientId:clientSecret" const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64"); // Make token refresh request to Spotify 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: refreshToken, }), }); if (!response.ok) { throw new Error(`Token refresh failed: ${response.status}`); } const data = await response.json(); if (data.error) { throw new Error(`Spotify auth error: ${data.error_description || data.error}`); } return data; };

Data Fetching with Fallbacks

Implement robust data fetching with comprehensive error handling:

// Helper function to create authorization headers const getAccessTokenHeader = (accessToken: string) => { return { headers: { Authorization: `Bearer ${accessToken}` } }; }; // Transform Spotify's complex track data into our simplified format const mapSpotifyData = (track: Track) => { return { songUrl: track.external_urls?.spotify || null, title: track.name || "Unknown Track", albumImageUrl: track.album?.images[0]?.url || null, artist: track.artists?.map((artist: { name: string }) => artist.name).join(", ") || "Unknown Artist", }; }; // Fallback function: get the most recently played track const getRecentlyPlayed = async (accessToken: string) => { try { const response = await fetch(`${BASE_URL}/recently-played?limit=1`, getAccessTokenHeader(accessToken)); if (!response.ok) { throw new Error(`Recently played API error: ${response.status}`); } const json = await response.json(); if (!json.items || json.items.length === 0) { return { isPlaying: false, songUrl: null, title: "No Recent Tracks", albumImageUrl: null, artist: "No listening history" }; } return { isPlaying: false, ...mapSpotifyData(json.items[0].track) }; } catch (error) { console.error("Recently played error:", error); return { isPlaying: false, songUrl: null, title: "Unable to fetch", albumImageUrl: null, artist: "Recent tracks unavailable" }; } };

Server-Side Caching

Implement intelligent caching to reduce API calls:

const getCachedData = (): SpotifyData | null => { if (!cache) return null; const now = Date.now(); if (now - cache.timestamp > CACHE_DURATION) { cache = null; // Expire cache return null; } return cache.data; }; const setCachedData = (data: SpotifyData): void => { cache = { data, timestamp: Date.now() }; };

Main Service Function

Bring it all together with the main service function:

const getSpotifyData = async (): Promise<SpotifyData> => { try { // Check cache first const cachedData = getCachedData(); if (cachedData) { return cachedData; } const tokenData = await getAccessToken(); if (!tokenData.access_token) { throw new Error("Failed to get access token"); } const { access_token } = tokenData; const nowPlayingResponse = await fetch(`${BASE_URL}/currently-playing`, getAccessTokenHeader(access_token)); // Handle different response statuses if (nowPlayingResponse.status === 204) { // No content - nothing is playing const recentData = await getRecentlyPlayed(access_token); setCachedData(recentData); return recentData; } if (nowPlayingResponse.status === 401) { throw new Error("Unauthorized - token may be expired"); } if (nowPlayingResponse.status === 403) { throw new Error("Forbidden - insufficient scopes"); } if (nowPlayingResponse.status === 429) { throw new Error("Rate limited - too many requests"); } if (!nowPlayingResponse.ok) { throw new Error(`Spotify API error: ${nowPlayingResponse.status}`); } const data = await nowPlayingResponse.json(); // Handle ads or missing track data if (!data.item || !data.item.name || !data.item.artists || data.currently_playing_type !== "track") { const recentData = await getRecentlyPlayed(access_token); setCachedData(recentData); return recentData; } const result = { isPlaying: data.is_playing, ...mapSpotifyData(data.item) }; setCachedData(result); return result; } catch (error) { console.error("Spotify API error:", error); // Return cached data if available, even if expired if (cache) { return cache.data; } // Return fallback data as last resort return { isPlaying: false, songUrl: null, title: "Unable to fetch", albumImageUrl: null, artist: "Spotify data unavailable" }; } }; export default getSpotifyData;

Server Component Implementation

Server Component Approach

With Next.js App Router, we can fetch data directly in Server Components, eliminating the need for client-side API calls:

// src/components/Bento/BentoItemNowPlaying/BentoItemNowPlaying.tsx import { cn } from "@/lib/utils"; import getSpotifyData from "@/lib/services/spotify"; const BentoItemNowPlaying = async () => { // Fetch data directly in the Server Component const data = await getSpotifyData(); return ( <a href={data?.songUrl || "https://open.spotify.com/"} target="_blank" rel="noopener noreferrer" className={cn( "group relative flex h-full items-center gap-x-6 rounded-3xl p-5", "max-lg:p-6 md:max-lg:flex-col md:max-lg:items-start md:max-lg:justify-between", "hover:bg-slate-50 transition-colors duration-200" )} > {/* Spotify Icon Badge */} <div className="absolute right-3 top-3"> <svg className="w-6 h-6 text-green-500 transition-all duration-300 group-hover:text-green-400" viewBox="0 0 24 24" fill="currentColor" > <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.42 1.56-.299.421-1.02.599-1.559.3z"/> </svg> </div> {/* Album Art */} <div className="aspect-square h-full rounded-xl bg-black p-3 max-lg:h-3/5 max-md:min-w-24"> <div className="relative"> <img src={data?.albumImageUrl || "/images/spotify-placeholder.png"} alt={data?.albumImageUrl ? `Album cover for ${data.title}` : "Spotify logo"} className={cn("absolute aspect-square rounded-full", { "animate-[spin_5s_linear_infinite]": data?.isPlaying, })} /> </div> </div> {/* Track Info */} <div className="w-full space-y-1 overflow-hidden tracking-wide"> <p className="text-sm text-slate-400"> {data?.isPlaying ? "Now playing" : "Last played"} </p> <div className="items-center gap-x-4 space-y-1 md:max-lg:flex"> <p className="max-w-full shrink-0 overflow-hidden text-ellipsis whitespace-nowrap font-medium"> {data?.title} </p> <p className="overflow-hidden text-ellipsis whitespace-nowrap text-sm uppercase text-slate-400"> {data?.artist} </p> </div> </div> </a> ); }; export default BentoItemNowPlaying;

Benefits of Server Components

1. Performance Benefits

  • No client-side JavaScript for data fetching
  • Faster initial page load
  • Better SEO as content is server-rendered
  • Reduced bundle size

2. Security Benefits

  • API credentials never exposed to client
  • No risk of client-side token exposure
  • Server-side caching reduces API calls

3. User Experience

  • No loading states needed
  • Content available immediately
  • Works without JavaScript enabled

Backward Compatibility with API Routes

For backward compatibility or client-side usage, maintain an API route that uses the same service:

// src/app/api/spotify/route.ts import { NextResponse } from "next/server"; import getSpotifyData from "@/lib/services/spotify"; export async function GET() { try { const data = await getSpotifyData(); return NextResponse.json(data); } catch (error) { console.error("Spotify API route error:", error); return NextResponse.json( { error: "Failed to fetch Spotify data" }, { status: 500 } ); } }

Client Component Alternative

If you need client-side updates (for real-time changes), you can create a client component:

"use client"; import { useEffect, useState } from "react"; import { SpotifyData } from "@/lib/services/spotify"; const ClientSpotifyWidget = ({ initialData }: { initialData: SpotifyData }) => { const [data, setData] = useState<SpotifyData>(initialData); useEffect(() => { const fetchData = async () => { try { const res = await fetch("/api/spotify"); if (res.ok) { const newData = await res.json(); setData(newData); } } catch (error) { console.error("Failed to fetch Spotify data:", error); } }; // Update every 30 seconds const interval = setInterval(fetchData, 30000); return () => clearInterval(interval); }, []); // Same JSX as server component... return ( // ... component JSX ); }; export default ClientSpotifyWidget;

Production Optimizations & Best Practices

Caching Strategies

1. Next.js Built-in Caching

// Use Next.js cache function for better performance import { cache } from "react"; export const getCachedSpotifyData = cache(async () => { return await getSpotifyData(); });

2. Revalidation

// In your page or layout export const revalidate = 15; // Revalidate every 15 seconds

3. On-Demand Revalidation

// API route for manual revalidation import { revalidatePath } from "next/cache"; export async function POST() { revalidatePath("/"); return Response.json({ revalidated: true }); }

Error Boundaries

Add error boundaries for production resilience:

// components/SpotifyErrorBoundary.tsx "use client"; import { ErrorBoundary } from "react-error-boundary"; function SpotifyFallback() { return ( <div className="flex items-center justify-center p-4 bg-gray-100 rounded-lg"> <p className="text-gray-600">Unable to load Spotify widget</p> </div> ); } export function SpotifyErrorBoundary({ children }: { children: React.ReactNode }) { return ( <ErrorBoundary FallbackComponent={SpotifyFallback}> {children} </ErrorBoundary> ); }

Monitoring & Observability

Add comprehensive logging for production:

const getSpotifyData = async (): Promise<SpotifyData> => { const startTime = Date.now(); try { // ... existing code ... console.log(`Spotify data fetched successfully in ${Date.now() - startTime}ms`); return result; } catch (error) { console.error("Spotify service error:", { error: error.message, duration: Date.now() - startTime, timestamp: new Date().toISOString(), stack: error.stack }); // ... error handling ... } };

Deployment & Configuration

Environment Setup

For production deployment (Vercel example):

1. Add Environment Variables

vercel env add SPOTIFY_CLIENT_ID vercel env add SPOTIFY_CLIENT_SECRET vercel env add SPOTIFY_REFRESH_TOKEN

2. Configure Caching

// next.config.js module.exports = { experimental: { staleTimes: { dynamic: 30, // 30 seconds for dynamic routes static: 180, // 3 minutes for static routes }, }, };

Testing Strategies

Create comprehensive tests:

// __tests__/spotify.service.test.ts import getSpotifyData from '../src/lib/services/spotify'; // Mock fetch globally global.fetch = jest.fn(); describe('Spotify Service', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should handle missing credentials gracefully', async () => { const originalEnv = process.env; process.env = {}; const result = await getSpotifyData(); expect(result.title).toBe('Unable to fetch'); expect(result.isPlaying).toBe(false); process.env = originalEnv; }); it('should return cached data when available', async () => { // Test caching logic }); it('should handle API errors gracefully', async () => { (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); const result = await getSpotifyData(); expect(result.title).toBe('Unable to fetch'); }); });

Architecture Comparison

Server Components vs Client-Side Fetching

AspectServer ComponentsClient-Side API
Performance✅ Faster initial load❌ Slower initial load
SEO✅ Server-rendered content❌ Content loaded after JS
Security✅ Credentials server-side only⚠️ API endpoint exposed
Real-time Updates❌ Requires page refresh✅ Can update without refresh
Bundle Size✅ No client-side fetching code❌ Larger bundle
Caching✅ Built-in Next.js caching⚠️ Manual implementation
Error Handling✅ Server-side error boundaries❌ Client-side error states

When to Use Each Approach

Use Server Components when:

  • SEO is important
  • Initial load performance is critical
  • Data doesn't need real-time updates
  • You want to minimize client-side JavaScript

Use Client Components when:

  • You need real-time updates
  • User interactions trigger data changes
  • You need complex client-side state management
  • Progressive enhancement is required

Extensions & Customizations

Advanced Features

1. Progressive Enhancement

// Combine both approaches const HybridSpotifyWidget = async () => { const initialData = await getSpotifyData(); return ( <div> {/* Server-rendered content */} <ServerSpotifyWidget data={initialData} /> {/* Client-side enhancement */} <ClientSpotifyWidget initialData={initialData} /> </div> ); };

2. Streaming with Suspense

import { Suspense } from "react"; const SpotifySection = () => { return ( <Suspense fallback={<SpotifyWidgetSkeleton />}> <BentoItemNowPlaying /> </Suspense> ); };

3. Edge Runtime Optimization

// For faster cold starts export const runtime = 'edge'; export async function GET() { const data = await getSpotifyData(); return Response.json(data); }

Conclusion

We've built a production-ready Spotify "Now Playing" widget using modern Next.js Server Components architecture that demonstrates:

Key Architectural Benefits

  1. Service Layer Pattern: Clean separation of concerns with reusable data fetching logic
  2. Server-Side Rendering: Better performance, SEO, and security
  3. Intelligent Caching: Reduced API calls with Next.js built-in caching
  4. Error Resilience: Comprehensive error handling with graceful fallbacks
  5. Type Safety: Complete TypeScript implementation
  6. Backward Compatibility: API routes available for client-side usage

What We Accomplished

  • ✅ Server-side data fetching with service layer architecture
  • ✅ Robust error handling and fallback strategies
  • ✅ Built-in caching with Next.js App Router
  • ✅ Beautiful, responsive UI component
  • ✅ Complete TypeScript implementation
  • ✅ Production-ready deployment guidance
  • ✅ Backward compatibility with API routes

Performance Improvements

  • Faster Initial Load: Server-rendered content available immediately
  • Better SEO: Content indexed by search engines
  • Reduced Bundle Size: No client-side data fetching code
  • Automatic Caching: Next.js handles caching automatically
  • Edge Runtime Ready: Can be deployed to edge functions for global performance

Next Steps

  • Implement the service layer in your project
  • Convert existing client components to server components where appropriate
  • Add comprehensive error boundaries
  • Set up monitoring and observability
  • Consider edge runtime deployment for global performance

Resources