Spotify Card

A premium Spotify card that fetches metadata and features a morphing album-to-disc animation.

The Spotify Card component is a premium, interactive interface that fetches live metadata from Spotify. When clicked, the album cover smoothly morphs into a rotating vinyl disc and plays an audio preview.

Preview

Usage

Installation

$ npx shadcn@latest add https://spark-ui-olive.vercel.app/r/spotify-card.json

API Setup

The Spotify Card requires a server-side route to fetch metadata. The registry should automatically create this at app/api/spotify/metadata/route.ts.

The route accepts a validated Spotify track ID (?trackId=...), never a raw URL, so the server only ever requests https://open.spotify.com/track/<id> — this prevents server-side request forgery (SSRF). The card extracts the track ID from your trackUrl prop automatically.

Note: If the registry fails to install the API route automatically, please manually create the file at app/api/spotify/metadata/route.ts and paste the code below.

import * as cheerio from "cheerio";
import got from "got";
import { NextRequest, NextResponse } from "next/server";

// SSRF guard: the outbound request destination is always built server-side
// from a validated Spotify track ID — never from a caller-supplied URL.
const TRACK_ID_RE = /^[A-Za-z0-9]{1,64}$/;
const MAX_RESPONSE_BYTES = 1_000_000;

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const trackId = searchParams.get("trackId") ?? "";

  if (!TRACK_ID_RE.test(trackId)) {
    return NextResponse.json({ error: "Invalid track ID" }, { status: 400 });
  }

  try {
    const request = got(`https://open.spotify.com/track/${trackId}`, {
      headers: {
        "user-agent": "Mozilla/5.0 (compatible; SparkUI-SpotifyCard/1.0)",
        accept: "text/html",
      },
      followRedirect: false,
      retry: { limit: 0 },
      timeout: { request: 5000 },
    });
    request.on("downloadProgress", ({ transferred }) => {
      if (transferred > MAX_RESPONSE_BYTES) request.cancel();
    });
    const { headers, body: html } = await request;

    if (!headers["content-type"]?.includes("text/html")) {
      return NextResponse.json(
        { error: "Failed to fetch metadata" },
        { status: 502 },
      );
    }

    const $ = cheerio.load(html);

    const title =
      $('meta[property="og:title"]').attr("content") || "Unknown Track";
    const image = $('meta[property="og:image"]').attr("content") || "";
    const previewUrl =
      $('meta[property="og:audio"]').attr("content") ||
      $('meta[name="twitter:audio:src"]').attr("content") ||
      "";

    // Spotify's og:description is often: "Artist · Album · Song · Year"
    const description =
      $('meta[property="og:description"]').attr("content") || "";
    // Usually the first part is the Artist
    const artist = description.split("·")[0]?.trim() || "Unknown Artist";

    return NextResponse.json({
      title,
      artist,
      albumArt: image,
      previewUrl,
    });
  } catch {
    // Non-2xx, redirect attempt, timeout, or oversized response. Keep the
    // client response generic — no upstream details.
    return NextResponse.json(
      { error: "Failed to fetch metadata" },
      { status: 502 },
    );
  }
}

Basic Example

import { SpotifyCard } from "@/components/spotify-card";

export default function App() {
  return (
    <div className="flex items-center justify-center p-8">
      <SpotifyCard trackUrl="https://open.spotify.com/track/3sK8wGT43QFpWrvNQsrQya" />
    </div>
  );
}

Properties

PropTypeDefaultDescription
trackUrlstringThe full Spotify track URL (https://open.spotify.com/track/<id>) to fetch metadata from.
classNamestringAdditional CSS classes for the card container.