Skip to content

October 31, 2025

Adding Live Last.fm Recent Tracks to My Next.js Site

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:

  1. API Key Security: You can't put API keys in client-side JavaScript—they'd be visible to anyone
  2. 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:

  1. Server-Side Data Fetching - Fetch tracks on the server for the initial render (no loading state!)
  2. Route Handler - A secure API endpoint that proxies requests to Last.fm
  3. 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 limit query 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 initialTracks from the server (no loading state needed!)
  • Uses useEffect to 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 revalidate option
  • 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

  1. Go to last.fm/api/account/create
  2. Fill out the form (you can use your website URL)
  3. Copy your API key
  4. Add it to your environment variables

Resources

More posts

Hello! I'm Brandon Templar, a product designer in Washington, D.C.
I am a designer, photographer, and tech enthusiast. Thanks for following along!