Overview
I wanted to showcase my music listening habits on my website by displaying my recently played tracks from Last.fm. The challenge was making this work dynamically—automatically updating as I listen to music—while keeping API keys secure. Here's how I built it with Next.js.
The Problem
When displaying data from external APIs like Last.fm, you face two main challenges:
- API Key Security: You can't put API keys in client-side JavaScript—they'd be visible to anyone
- Fresh Data: You want the tracks to update automatically without requiring a page refresh
I needed a solution that kept my API keys secure on the server while still providing live updates to users.
The Solution: Server Components + Route Handlers
Next.js provides a clean architecture for this:
- Server-Side Data Fetching - Fetch tracks on the server for the initial render (no loading state!)
- Route Handler - A secure API endpoint that proxies requests to Last.fm
- Client Component - Polls the Route Handler every 60 seconds for live updates
The Implementation
File Structure
src/
├── app/
│ ├── api/
│ │ └── lastfm/
│ │ └── tracks/
│ │ └── route.ts # API Route Handler
│ └── page.tsx # Homepage (uses RecentTracks)
├── components/
│ ├── RecentTracks.tsx # Client component with polling
│ └── MusicTrackItem.tsx # Individual track display
└── lib/
└── lastfm/
└── index.ts # Server-side fetch function
1. Server-Side Data Fetching (src/lib/lastfm/index.ts)
This function runs on the server and is used for the initial page render. It fetches tracks directly from Last.fm using the API key stored in environment variables.
const LASTFM_API_BASE = 'https://ws.audioscrobbler.com/2.0/';
export interface LastFmTrack {
name: string;
artist: string;
album: string;
image: string;
url: string;
nowPlaying: boolean;
}
export async function getRecentTracks(limit: number = 8): Promise<LastFmTrack[]> {
const apiKey = process.env.LASTFM_API_KEY;
const username = process.env.LASTFM_USERNAME;
if (!apiKey || !username) {
console.warn('Last.fm API key or username not configured');
return [];
}
try {
const params = new URLSearchParams({
method: 'user.getrecenttracks',
user: username,
api_key: apiKey,
format: 'json',
limit: limit.toString(),
});
const response = await fetch(`${LASTFM_API_BASE}?${params}`, {
next: { revalidate: 300 }, // Cache for 5 minutes
});
if (!response.ok) {
throw new Error(`Last.fm API error: ${response.status}`);
}
const data = await response.json();
return data.recenttracks.track.map((track) => ({
name: track.name,
artist: track.artist['#text'],
album: track.album['#text'],
image: getImageUrl(track.image),
url: track.url,
nowPlaying: track['@attr']?.nowplaying === 'true',
}));
} catch (error) {
console.error('Error fetching Last.fm tracks:', error);
return [];
}
}
function getImageUrl(images): string {
const extraLarge = images.find((img) => img.size === 'extralarge');
const large = images.find((img) => img.size === 'large');
const medium = images.find((img) => img.size === 'medium');
return extraLarge?.['#text'] || large?.['#text'] || medium?.['#text'] || '';
}
Key points:
- Uses
next: { revalidate: 300 }to cache the response for 5 minutes - API key stays on the server—never sent to the browser
- Returns a clean, typed array of tracks
2. API Route Handler (src/app/api/lastfm/tracks/route.ts)
This Route Handler acts as a secure proxy for client-side requests. It's called by the React component when polling for updates.
import { NextResponse } from 'next/server';
const LASTFM_API_BASE = 'https://ws.audioscrobbler.com/2.0/';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const limit = searchParams.get('limit') || '8';
const apiKey = process.env.LASTFM_API_KEY;
const username = process.env.LASTFM_USERNAME;
if (!apiKey || !username) {
return NextResponse.json({ tracks: [] });
}
try {
const params = new URLSearchParams({
method: 'user.getrecenttracks',
user: username,
api_key: apiKey,
format: 'json',
limit,
});
const response = await fetch(`${LASTFM_API_BASE}?${params}`, {
next: { revalidate: 60 }, // Cache for 1 minute
});
if (!response.ok) {
throw new Error(`Last.fm API error: ${response.status}`);
}
const data = await response.json();
const tracks = data.recenttracks.track.map((track) => ({
name: track.name,
artist: track.artist['#text'],
album: track.album['#text'],
image: getImageUrl(track.image),
url: track.url,
nowPlaying: track['@attr']?.nowplaying === 'true',
}));
return NextResponse.json({ tracks });
} catch (error) {
console.error('Error fetching Last.fm tracks:', error);
return NextResponse.json({ tracks: [] });
}
}
Key points:
- Accepts a
limitquery parameter - Uses shorter cache (1 minute) since this is for live updates
- Returns JSON that the client component can consume
3. Client Component with Polling (src/components/RecentTracks.tsx)
This component receives the initial tracks from the server and then polls for updates every 60 seconds.
'use client';
import { useState, useEffect } from 'react';
import styled from 'styled-components';
import MusicTrackItem from './MusicTrackItem';
import type { LastFmTrack } from '@/lib/lastfm';
interface RecentTracksProps {
tracks: LastFmTrack[];
}
const POLL_INTERVAL = 60000; // 60 seconds
const TracksGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: ${({ theme }) => theme.spacing[5]};
`;
export default function RecentTracks({ tracks: initialTracks }: RecentTracksProps) {
const [tracks, setTracks] = useState<LastFmTrack[]>(initialTracks);
useEffect(() => {
const fetchTracks = async () => {
try {
const response = await fetch('/api/lastfm/tracks?limit=8');
if (response.ok) {
const data = await response.json();
if (data.tracks && data.tracks.length > 0) {
setTracks(data.tracks);
}
}
} catch (error) {
console.error('Error fetching recent tracks:', error);
}
};
const interval = setInterval(fetchTracks, POLL_INTERVAL);
return () => clearInterval(interval);
}, []);
if (!tracks || tracks.length === 0) {
return null;
}
return (
<TracksGrid>
{tracks.map((track, index) => (
<MusicTrackItem key={`${track.name}-${track.artist}-${index}`} track={track} />
))}
</TracksGrid>
);
}
Key points:
'use client'directive makes this a Client Component- Receives
initialTracksfrom the server (no loading state needed!) - Uses
useEffectto set up polling interval - Cleans up interval on unmount
4. Using It in a Page
On the homepage or any server component, fetch the initial data and pass it to the client component:
import { getRecentTracks } from '@/lib/lastfm';
import RecentTracks from '@/components/RecentTracks';
export default async function HomePage() {
const tracks = await getRecentTracks(8);
return (
<main>
<h1>Recently Played</h1>
<RecentTracks tracks={tracks} />
</main>
);
}
How It Works Together
Initial Page Load
User visits the page
↓
Next.js server renders the page
↓
getRecentTracks() fetches from Last.fm (server-side)
↓
HTML includes the tracks already rendered
↓
User sees tracks immediately (no loading spinner!)
Live Updates
Every 60 seconds...
↓
Client component fetches /api/lastfm/tracks
↓
Route Handler fetches from Last.fm (API key stays secure)
↓
New tracks returned to client
↓
React updates the UI
↓
User sees fresh data without refreshing!
Environment Variables
Create a .env.local file (never commit this!):
LASTFM_API_KEY=your_api_key_here
LASTFM_USERNAME=your_lastfm_username
For production, add these to your hosting provider's environment variables (Vercel, Netlify, etc.).
Benefits of This Approach
Security
- API keys never appear in browser code
- All Last.fm requests go through your server
- No way for users to see or steal credentials
Performance
- No loading state on initial page load (server-rendered)
- Built-in caching with
revalidateoption - Efficient polling (only every 60 seconds)
Developer Experience
- Full TypeScript support with shared types
- No separate serverless function configuration
- Everything is part of the Next.js app
User Experience
- Tracks appear instantly on page load
- Automatic updates without page refresh
- Graceful error handling (falls back to cached data)
Getting a Last.fm API Key
- Go to last.fm/api/account/create
- Fill out the form (you can use your website URL)
- Copy your API key
- Add it to your environment variables