Adding a Last.fm Now Playing Widget to Your Website

5 min read

Music is a fundamental part of many developers’ workflows, and sharing what you’re currently listening to can add a personal touch to your website. In this guide, we’ll build a dynamic “Now Playing” widget that displays your current Last.fm track with real-time updates, perfect for your personal site or portfolio.

Why Add a Now Playing Widget?

Adding a music widget to your personal site serves several purposes:

  • Personal connection: Shows visitors what inspires you while you work
  • Dynamic content: Keeps your site feeling alive with real-time updates
  • Low maintenance: Once set up, it runs automatically without intervention
  • Conversation starter: Creates common ground with fellow music enthusiasts

Architecture Overview

Our Now Playing widget consists of three main components:

  1. Astro Component: Handles the UI and client-side JavaScript
  2. Server-side API Endpoint: Securely accesses your API key and proxies requests
  3. Last.fm API: Provides real-time track data

The flow works like this:

Browser → Your API Endpoint → Last.fm API
   ↑                               ↓
   └─── Update UI ← Parse Response ┘
          ↑                        ↓
          └──── 10s interval ──────┘

This architecture keeps your API key secure on the server while providing real-time updates to the client.

Implementation

Let’s build the widget step by step. We’ll create an Astro component that can be easily integrated into any page.

Part 1: Creating the Component

First, create a new Astro component for the Now Playing widget:

---
// src/components/LastFmNowPlaying.astro
export interface Props {
  username: string;
}

const { username } = Astro.props;
---

<div id="lastfm-now-playing" class="text-xs text-gray-600 dark:text-gray-400">
  <div class="flex items-center gap-2">
    <svg class="w-4 h-4 animate-pulse text-green-500" fill="currentColor" viewBox="0 0 24 24">
      <circle cx="12" cy="12" r="3"></circle>
      <path
        d="M12 6a6 6 0 0 0-6 6c0 1.66.67 3.16 1.76 4.24l1.42-1.42A4 4 0 0 1 8 12a4 4 0 0 1 4-4V6z"
        opacity="0.6"></path>
      <path
        d="M12 2a10 10 0 0 0-10 10c0 2.76 1.12 5.26 2.93 7.07l1.42-1.42A8 8 0 0 1 4 12a8 8 0 0 1 8-8V2z"
        opacity="0.3"></path>
    </svg>
    <span id="track-info" class="italic">Loading...</span>
  </div>
</div>

The component includes:

  • Animated icon: A pulsing broadcast icon indicating live status
  • Loading state: Shows “Loading…” while fetching data
  • Flexible styling: Uses Tailwind classes that adapt to light/dark themes

Part 2: Creating the API Endpoint

For production deployments, especially on platforms like Cloudflare Pages, we need to keep our API key secure. Create a server-side endpoint:

// src/pages/api/lastfm.json.ts
import type { APIRoute } from 'astro';

export const prerender = false; // This endpoint runs on the server

export const GET: APIRoute = async (context) => {
  const url = new URL(context.request.url);
  const username = url.searchParams.get('username');

  if (!username) {
    return new Response(JSON.stringify({ error: 'Username required' }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  // Access the API key from environment variables
  const runtime = (context.locals as any).runtime;
  const apiKey = runtime?.env?.PUBLIC_LASTFM_API_KEY || import.meta.env.PUBLIC_LASTFM_API_KEY;

  if (!apiKey) {
    return new Response(JSON.stringify({ error: 'Last.fm API key not configured' }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  try {
    const lastFmResponse = await fetch(
      `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${username}&api_key=${apiKey}&format=json&limit=1`
    );

    const data = await lastFmResponse.json();

    return new Response(JSON.stringify(data), {
      status: 200,
      headers: {
        'Content-Type': 'application/json',
        'Cache-Control': 'no-cache, no-store, must-revalidate',
      },
    });
  } catch (error) {
    return new Response(JSON.stringify({ error: 'Failed to fetch Last.fm data' }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' },
    });
  }
};

Part 3: Client-side JavaScript

Now update the component to use your API endpoint:

<script define:vars={{ username }}>
  async function fetchNowPlaying() {
    try {
      const response = await fetch(`/api/lastfm.json?username=${username}`);
      const data = await response.json();

      const trackElement = document.getElementById('track-info');
      if (!trackElement) return;

      if (data.error) {
        trackElement.textContent = 'Unable to load track info';
        return;
      }

      if (data.recenttracks && data.recenttracks.track) {
        // Handle both array and single object responses
        const tracks = Array.isArray(data.recenttracks.track)
          ? data.recenttracks.track
          : [data.recenttracks.track];

        if (tracks.length > 0) {
          const track = tracks[0];
          const isNowPlaying = track['@attr'] && track['@attr'].nowplaying === 'true';

          if (isNowPlaying) {
            trackElement.innerHTML = `Now playing: <strong>${track.name}</strong> by ${track.artist['#text']}`;
          } else {
            // Show last played track with timestamp
            const date = track.date ? new Date(track.date.uts * 1000) : null;
            const timeAgo = date ? getTimeAgo(date) : '';
            trackElement.innerHTML = `Last played: <strong>${track.name}</strong> by ${track.artist['#text']}${timeAgo ? ' · ' + timeAgo : ''}`;
          }
        } else {
          trackElement.textContent = 'No recent tracks';
        }
      } else {
        trackElement.textContent = 'No recent tracks';
      }
    } catch (error) {
      const trackElement = document.getElementById('track-info');
      if (trackElement) {
        trackElement.textContent = 'Unable to load track info';
      }
    }
  }

  function getTimeAgo(date) {
    const seconds = Math.floor((new Date() - date) / 1000);

    const intervals = {
      year: 31536000,
      month: 2592000,
      week: 604800,
      day: 86400,
      hour: 3600,
      minute: 60,
    };

    for (const [name, secondsInInterval] of Object.entries(intervals)) {
      const interval = Math.floor(seconds / secondsInInterval);
      if (interval >= 1) {
        return interval === 1 ? `1 ${name} ago` : `${interval} ${name}s ago`;
      }
    }

    return 'just now';
  }

  // Fetch immediately
  fetchNowPlaying();

  // Update every 10 seconds for better real-time updates
  setInterval(fetchNowPlaying, 10000);
</script>

Key implementation details:

  • Secure API endpoint: Your server-side endpoint keeps the API key secure
  • Real-time detection: Checks the nowplaying attribute to determine if a track is currently playing
  • Enhanced display: Shows “Last played” with time ago when not currently playing
  • Robust parsing: Handles both array and single object API responses
  • Error handling: Gracefully handles network errors and missing data
  • Auto-refresh: Updates every 10 seconds for near real-time updates

Part 4: Astro Configuration

To enable server-side endpoints, update your Astro configuration:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  // ... other config
  output: 'hybrid',
  adapter: cloudflare({
    mode: 'directory',
  }),
});

Don’t forget to install the adapter:

npm install @astrojs/cloudflare

Part 5: Integration

To use the component in your site, simply import and include it with your Last.fm username:

---
// In your page or component
import LastFmNowPlaying from '@components/LastFmNowPlaying.astro';
---

<!-- Basic usage -->
<LastFmNowPlaying username="yourusername" />

<!-- Integration with a link card -->
<div class="link-card">
  <h3>Last.fm</h3>
  <p>Music tracking and discovery</p>
  <div class="mt-auto pt-2 border-t border-gray-200 dark:border-gray-700">
    <LastFmNowPlaying username="yourusername" />
  </div>
</div>

API Configuration

To use the Last.fm API, you’ll need your own API key:

  1. Register your application at Last.fm API
  2. Get your API key from the account dashboard
  3. Store it securely (never commit API keys to your repository)

Local Development

For local development, create a .env file in your project root:

PUBLIC_LASTFM_API_KEY=your_actual_api_key_here

Production Deployment (Cloudflare Pages)

For production, use Cloudflare’s secret management:

  1. Via Dashboard:

    • Go to your Pages project → Settings → Environment variables
    • Add PUBLIC_LASTFM_API_KEY as a Secret (not plaintext)
    • Secrets are encrypted and only accessible at runtime
  2. Via Wrangler CLI:

    wrangler pages secret put PUBLIC_LASTFM_API_KEY --project-name=your-project-name
    

Important: When using Cloudflare Pages with secrets, you must:

  • Use the hybrid output mode in Astro
  • Install and configure the Cloudflare adapter
  • Create a server-side endpoint to access the secret

For other platforms, consult their documentation on setting environment variables.

Styling Variations

The widget can be customized to match your site’s design. Here are some variations:

/* Minimal style */
.now-playing-minimal {
  font-size: 0.875rem;
  color: var(--text-secondary);
}

/* Card style */
.now-playing-card {
  padding: 1rem;
  background: var(--bg-secondary);
  border-radius: 0.5rem;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

/* Inline style */
.now-playing-inline {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
}

Troubleshooting Common Issues

”API key missing” Error

If you see this in the console:

  • Local: Ensure your .env file exists and contains PUBLIC_LASTFM_API_KEY
  • Production: Check that the secret is properly set in Cloudflare Pages
  • Build vs Runtime: Remember that secrets are only available at runtime, not during build

”No recent tracks” When Playing Music

  • Ensure your Last.fm account is set to public
  • Check that your music player is scrobbling correctly
  • The API sometimes returns tracks as a single object instead of an array

TypeScript Errors

If you get TypeScript errors about context.locals.runtime:

const runtime = (context.locals as any).runtime;

Missing Dependencies

If you encounter Cannot find module 'tslib' error:

npm install tslib

Best Practices

When implementing a Now Playing widget, consider these best practices:

  1. Security: Never expose your API key in client-side code - always use a server endpoint

  2. Rate limiting: Last.fm allows 5 requests per second per API key. The 10-second interval ensures you stay well within limits

  3. Caching: The endpoint includes no-cache headers to ensure fresh data

  4. Privacy: Only display information you’re comfortable sharing publicly

  5. Fallback content: Always provide meaningful fallback text for error states

  6. Performance: The widget loads asynchronously and doesn’t block page rendering

Advanced Features

You can extend the widget with additional features:

  • Album artwork: Display cover art using the image field from the API response
  • Track progress: Show how long the track has been playing
  • Recently played: Display a list of recent tracks when nothing is currently playing
  • Click to play: Link to the track on Last.fm or Spotify

Example with album art:

if (track.image && track.image[0]) {
  const albumArt = track.image[0]['#text'];
  const display = isNowPlaying
    ? `Now playing: <strong>${track.name}</strong>`
    : `Last played: <strong>${track.name}</strong>`;

  trackElement.innerHTML = `
    <div class="flex items-center gap-2">
      <img src="${albumArt}" alt="Album art" class="w-8 h-8 rounded">
      <div>
        ${display} by ${track.artist['#text']}
      </div>
    </div>
  `;
}

Conclusion

Adding a Last.fm Now Playing widget is a simple way to bring dynamic, personal content to your website. With just a few lines of code, you can share your musical tastes with visitors and create a more engaging experience. The real-time updates keep your site feeling fresh, while the minimal implementation ensures it doesn’t impact performance.

Whether you’re building a personal portfolio, blog, or any site where you want to add a human touch, this widget provides an easy way to connect with visitors through the universal language of music.