Blog
Jun 8, 2025 - 7 MIN READ
Building a Spotify Now Playing Widget with Nuxt 3

Building a Spotify Now Playing Widget with Nuxt 3

How to integrate the Spotify API with Nuxt 3 to display your currently playing track in real-time on your personal website or portfolio.

Pheak Minute

Pheak Minute

Adding a "Now Playing" widget to your personal website is a great way to personalize your online presence and showcase your music taste. In this tutorial, I'll show you how to integrate the Spotify API with Nuxt 3 to display your currently playing track in real-time.

Setting Up Spotify API Access

Before diving into code, you'll need to set up your Spotify Developer account and create an application:

  1. Visit the Spotify Developer Dashboard and log in
  2. Create a new application
  3. Set a redirect URI (e.g., http://localhost:3000/callback)
  4. Note down your client_id and client_secret
  5. Generate a refresh token (we'll use this for persistent access)

Project Structure

For our implementation, we'll need three main components:

  1. Utility functions for Spotify API authentication and data fetching
  2. A server API endpoint to securely fetch the currently playing track
  3. A Vue component to display the widget on the frontend

Step 1: Creating Spotify Utility Functions

First, let's create utility functions to handle authentication and API requests. Create a file named utils/spotify.ts:

const config = useRuntimeConfig();
const client_id = config.public.clientId;
const client_secret = config.public.clientSecret;
const refresh_token = config.public.refreshToken;

const basic = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
const TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";
const RECENT_TRACKS_ENDPOINT =
  "https://api.spotify.com/v1/me/player/recently-played?limit=5";
const NOW_PLAYING_ENDPOINT =
  "https://api.spotify.com/v1/me/player/currently-playing";

export const getAccessToken = async () => {
  const response = await fetch(TOKEN_ENDPOINT, {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: refresh_token as string,
    }),
  });

  return response.json();
};

export const getRecentTracks = async () => {
  const { access_token } = await getAccessToken();

  return fetch(RECENT_TRACKS_ENDPOINT, {
    headers: {
      Authorization: `Bearer ${access_token}`,
    },
  });
};

export const getNowPlaying = async () => {
  const { access_token } = await getAccessToken();

  return fetch(NOW_PLAYING_ENDPOINT, {
    headers: {
      Authorization: `Bearer ${access_token}`,
    },
  });
};

The code above handles three main functions:

  • getAccessToken: Authenticates with Spotify using our client credentials and refresh token
  • getRecentTracks: Fetches our recently played tracks (optional feature)
  • getNowPlaying: Fetches what's currently playing on our Spotify account

Step 2: Creating a Server API Endpoint

Next, let's create a server API endpoint to securely fetch the currently playing track. Create a file named server/api/spotify/now-playing.ts:

import { getNowPlaying } from "../../../utils/spotify";

export default defineEventHandler(async (event) => {
  try {
    const response = await getNowPlaying();

    if (response.status === 204 || response.status > 400) {
      return { isPlaying: false };
    }

    const song = await response.json();

    if (!song.item) {
      return { isPlaying: false };
    }

    const isPlaying = song.is_playing;
    const title = song.item.name;
    const artist = song.item.artists
      .map((_artist: any) => _artist.name)
      .join(", ");
    const album = song.item.album.name;
    const albumImageUrl = song.item.album.images[0]?.url;
    const songUrl = song.item.external_urls.spotify;
    const progress = song.progress_ms;
    const duration = song.item.duration_ms;

    // Set appropriate cache headers
    setResponseHeaders(event, {
      "Cache-Control": "public, s-maxage=60, stale-while-revalidate=30",
    });

    return {
      album,
      albumImageUrl,
      artist,
      isPlaying,
      songUrl,
      title,
      progress,
      duration,
    };
  } catch (error) {
    console.error("Error fetching now playing:", error);
    throw createError({
      statusCode: 500,
      statusMessage: "Failed to fetch Spotify data",
    });
  }
});

This endpoint:

  1. Calls our getNowPlaying utility function to fetch data from Spotify
  2. Handles various states (not playing, errors)
  3. Processes and structures the response data
  4. Implements caching for performance optimization
  5. Returns the formatted data for our frontend component

Step 3: Creating the Frontend Component

Finally, let's create a Vue component to display the currently playing track. Create a file named components/NowPlaying.vue:

<script setup lang="ts">
interface NowPlayingResponse {
  isPlaying: boolean;
  title?: string;
  artist?: string;
  albumImageUrl?: string;
  songUrl?: string;
  progress?: number;
  duration?: number;
}

// Use useFetch with auto-refresh
const { data, error, pending, refresh } = await useFetch<NowPlayingResponse>(
  "/api/spotify/now-playing",
  {
    default: () => ({
      isPlaying: false,
      title: undefined,
      artist: undefined,
      albumImageUrl: undefined,
      songUrl: undefined,
      progress: undefined,
      duration: undefined,
    }),
    server: false, // Only run on client side
  }
);

// Manual refresh every 10 seconds
useIntervalFn(() => {
  refresh();
}, 10000);

const progressPercentage = computed(() => {
  const progress = data.value?.progress || 0;
  const duration = data.value?.duration || 1;
  return (progress / duration) * 100;
});

const isPlaying = computed(() => data.value?.isPlaying || false);
</script>

<template>
  <Motion
    :initial="{
      scale: 1.1,
      opacity: 0,
      filter: 'blur(20px)',
    }"
    :animate="{
      scale: 1,
      opacity: 1,
      filter: 'blur(0px)',
    }"
    :transition="{
      duration: 0.6,
      delay: 0.1,
    }"
  >
    <div
      v-if="error || pending || !data || !data.isPlaying"
      class="flex items-center gap-2 text-xs text-muted"
    >
      <UIcon name="i-heroicons-musical-note" class="w-3.5 h-3.5" />
      <span>Not playing</span>
    </div>

    <ULink
      v-else
      :to="data?.songUrl || '#'"
      target="_blank"
      rel="noopener noreferrer"
      class="group"
    >
      <div
        class="flex items-center gap-3 bg-muted/50 backdrop-blur-md p-1.5 pr-3 rounded-full hover:bg-muted transition-all duration-300 border border-neutral-200/40 dark:border-neutral-700/40"
      >
        <!-- Album Cover with Animation -->
        <div
          class="relative h-7 w-7 rounded-full overflow-hidden flex-shrink-0"
          :style="{
            animation: isPlaying ? 'spin 15s linear infinite' : 'none',
          }"
        >
          <div
            class="absolute inset-0 bg-black/20 backdrop-blur-[1px] z-10 rounded-full"
          />
          <img
            v-if="data?.albumImageUrl"
            :src="data.albumImageUrl"
            :alt="data.title || 'Album cover'"
            class="h-full w-full object-cover"
          />
          <div
            class="absolute inset-[30%] bg-elevated backdrop-blur-sm z-20 rounded-full flex items-center justify-center"
          >
            <UIcon
              v-if="isPlaying"
              name="i-heroicons-pause"
              class="w-2.5 h-2.5 text-highlighted"
            />
            <UIcon
              v-else
              name="i-heroicons-play"
              class="w-2.5 h-2.5 text-highlighted"
            />
          </div>
        </div>

        <!-- Track Info -->
        <div class="flex flex-col min-w-0 max-w-[120px] sm:max-w-[160px]">
          <div class="text-xs font-medium truncate text-highlighted">
            {{ data?.title }}
          </div>
          <div class="text-[10px] text-muted truncate">
            {{ data?.artist }}
          </div>
        </div>

        <!-- Progress Bar -->
        <div class="w-10 sm:w-12 h-1 rounded-full bg-muted overflow-hidden">
          <div
            :style="{ width: `${progressPercentage}%` }"
            class="h-full bg-primary-500 transition-all duration-300"
          />
        </div>
      </div>
    </ULink>
  </Motion>
</template>

<style scoped>
@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
</style>

This component:

  1. Defines a TypeScript interface for our API response
  2. Uses Nuxt's useFetch composable to call our API endpoint
  3. Implements auto-refreshing every 10 seconds to keep the display updated
  4. Calculates the progress percentage for the progress bar
  5. Handles different states (playing, not playing, loading, error)
  6. Renders a beautiful UI with album art that spins while playing
  7. Includes a progress bar that updates in real-time

Step 4: Environment Configuration

To keep our API credentials secure, we need to set up environment variables. Create or update your .env file:

NUXT_PUBLIC_CLIENT_ID=your_spotify_client_id
NUXT_PUBLIC_CLIENT_SECRET=your_spotify_client_secret
NUXT_PUBLIC_REFRESH_TOKEN=your_spotify_refresh_token

Then, update your nuxt.config.ts file to expose these variables to our application:

export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      clientId: process.env.NUXT_PUBLIC_CLIENT_ID,
      clientSecret: process.env.NUXT_PUBLIC_CLIENT_SECRET,
      refreshToken: process.env.NUXT_PUBLIC_REFRESH_TOKEN,
    },
  },
  // rest of your Nuxt config
});

Step 5: Using the Component

Now you can use the component anywhere in your application:

<template>
  <div>
    <h2>Currently Listening To</h2>
    <NowPlaying />
  </div>
</template>

Technical Considerations and Optimizations

Caching Strategy

Our implementation includes server-side caching via the Cache-Control header. This prevents excessive API calls to Spotify while still keeping the display relatively fresh. We've set a 60-second cache with a 30-second stale-while-revalidate window.

Authentication Flow

We're using a refresh token for authentication, which means we don't need user interaction to keep the token fresh. The refresh token will work indefinitely unless revoked.

Performance Optimizations

  1. The component only refreshes every 10 seconds to minimize API calls
  2. We're using client-side hydration to avoid unnecessary server load
  3. The progress bar updates smoothly via CSS transitions

Conclusion

With these components in place, you now have a beautiful, real-time Spotify "Now Playing" widget on your Nuxt 3 website. This integration showcases not only your music taste but also demonstrates how to work with third-party APIs in a Nuxt application.

The solution we've built is efficient, secure, and visually appealing. You can further customize the styling to match your website's design or add additional features like recently played tracks or favorite artists.

What music integrations would you like to see next? Let me know in the comments below!

Not playing
Copyright © 2025