Spotify catalog — with the owner's own Spotify app
prompts/pkg/llms/spotify.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
When someone asks for a page built on Spotify's catalog — search an artist, show
an album's tracks, chart the tempo of a playlist's songs — build it on the Spotify
Web API with app credentials the owner supplies. This is a backend.js feature
(see the backend docs for the general handler contract); this page is the recipe
that makes the credentials safe, the token short-lived and unstored, and the app
honest about which half of Spotify it can actually see.
This is the owner-dashboard pattern, and it has a boundary. The credentials are the owner's, the rate limit is the owner's, and every visitor sees the same public catalog. That is exactly right for "let people search Spotify from my page" and exactly wrong for "show each visitor their own top tracks". An app where each person who opens it sees their own Spotify account is a connection — the platform renders the consent screen, runs the sign-in, holds the credential and refreshes it, and your code never sees a token (see the connections page). So never render an input asking a visitor to paste an access token, and never send a visitor to a developer console to mint one: that is the flow connections exists to replace. The console trip below is for the owner of this app, once, and for nobody else.
1. Bring your own app credentials, and explain how
The owner registers an app with Spotify, once:
- Open the Spotify developer dashboard and log in with a Spotify account.
- Create an app — any name and description; a redirect URI is not used by this flow, so put the app's own URL in if the form insists on one.
- Open the app's settings and copy the Client ID.
- Reveal and copy the Client secret.
- Set both on the vibe as
SPOTIFY_CLIENT_IDandSPOTIFY_CLIENT_SECRET— the vibe's settings page, orvibes-diy secrets set SPOTIFY_CLIENT_ID <the id>andvibes-diy secrets set SPOTIFY_CLIENT_SECRET <the secret>.
The app carries those five steps in its own UI, because the person reading them is the person who has to do them.
When the credentials are missing, the app shows the owner the setup steps for a Spotify app instead of an error.
The backend answers { ok: false, reason: "missing-key" } when either
ctx.secrets.SPOTIFY_CLIENT_ID or ctx.secrets.SPOTIFY_CLIENT_SECRET is absent,
and the client renders the numbered steps, naming the Spotify developer dashboard
and both secret names in so many words so the owner knows what to create and what
to call it. Two secrets means two ways to be half-configured: say "missing-key"
once and list both, rather than letting one set and one unset look like a broken
API.
Only the owner can set or rotate a secret, and handlers see the current value on every invocation — so the page starts working the moment the pair lands, with no redeploy.
2. The credentials never reach the browser
Every Spotify request is made in backend.js with ctx.fetch and the two ctx.secrets values; App.jsx never calls api.spotify.com or accounts.spotify.com.
The client talks to the app's own /_api/... routes with a relative fetch, and
the backend is the only place that knows the client id and secret exist. A client
secret pasted into a React component ships to every visitor who views source, and
anyone who reads it can mint tokens against the owner's app until it is rotated.
The client id alone is no better: it is half of the pair and it names the owner's
app to anyone collecting them.
3. Mint a token, hold it in memory, never write it down
The public catalog is not open. GET https://api.spotify.com/v1/search with no
Authorization header answers 401 Missing/invalid/expired access token —
there is no anonymous catalog access, so every app on this page mints a token
before its first call.
This is the client-credentials flow: server to server, with no redirect, no user login and no callback route. Do not add one; there is nothing for a visitor to approve.
POST https://accounts.spotify.com/api/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=<SPOTIFY_CLIENT_ID>&client_secret=<SPOTIFY_CLIENT_SECRET>
Spotify documents two ways to present the pair: in the form body as above, or as
HTTP Basic auth over client_id:client_secret. Use the form body. It is one
less thing to base64-encode and it keeps the request shape plain; both are
accepted by the same endpoint.
The answer is { access_token, token_type: "Bearer", expires_in }, and
expires_in is seconds — about an hour. Every catalog call then carries
Authorization: Bearer <access_token>.
The token is cached in a module-scope variable in backend.js and re-minted when it expires or when a call answers 401 — it is never written to a document.
A token in a document is a credential at rest with a readable lifetime and no reason to exist: it buys nothing a fresh mint does not, it outlives the isolate that needed it, and anything that can read the app's data can then spend the owner's app. Hold it beside the handler, treat a cold isolate as a cache miss, and mint again. Re-mint slightly early — a token that expires mid-request looks like a broken API — and re-mint once on a 401 rather than surfacing it, since a revoked or rotated secret and an expired token arrive as the same status.
4. What client credentials buys, and what it does not
Client credentials buys the public catalog and nothing that belongs to a listener.
| Available | Not available with client credentials |
|---|---|
search across tracks, artists, albums, playlists |
"my top tracks", "my recently played" |
| an artist, their albums, their top tracks | the visitor's saved songs or library |
| an album and its tracks | the visitor's own playlists |
| a track and its audio features | playback, queue, or anything that controls a player |
| public playlists and their tracks | private playlists, follows, or listening history |
Everything in the right column needs the visitor's own Spotify account, which is a connection and not this flow. Do not imply the app can reach it, do not add an input that asks for a token to unlock it, and do not render an empty "Your top tracks" section waiting for a capability that will never arrive. If the person asked for their own listening data, say plainly that this page reads Spotify's public catalog and point at the connections page.
5. Reading the catalog
GET https://api.spotify.com/v1/search?q=<terms>&type=track,artist&limit=20
GET https://api.spotify.com/v1/artists/<id>/top-tracks?market=US
GET https://api.spotify.com/v1/albums/<id>
GET https://api.spotify.com/v1/tracks?ids=<up to 50 comma-joined ids>
Each answers JSON with a paging object — items, total, limit, offset — so
page with offset rather than asking for a bigger limit than the endpoint allows
(50 for most, 20 for search per type). Several endpoints want a market; without
one an item can come back playable nowhere and render as a blank row.
An empty items array means nothing matched — say that, and keep the search terms
on screen so the person can fix them. Images arrive as a images array ordered
largest first; take the smallest one that is big enough rather than the first.
6. Errors keep their reason
Spotify answers a refusal with { "error": { "status", "message" } } on the Web
API, and { "error", "error_description" } on the token endpoint. Classify and
pass the reason through:
invalid_clientfrom the token endpoint — the id and secret do not match a real app. Show the setup steps from section 1 again.401from the Web API — mint a fresh token and retry once; if the second try is also 401, show the setup steps.429— Spotify is rate-limiting the owner's app. TheRetry-Afterheader is seconds; say "Spotify asked us to wait N seconds" rather than "no results".404— no such artist, album or track. Keep the id on screen.- anything else — show the API's own
error.message.
A failure keeps its reason all the way to the screen. "No data" tells the owner nothing they can act on, and the five cases above each have a different fix.
Every _api route wraps its handler in try/catch and answers with a stated
reason, because an uncaught throw reaches the app as a bare 500 backend.js threw
naming only the error class, and the app can show the person what to fix only
when the route names it.
Complete backend.js
backend.js
jsconst TOKEN_URL = "https://accounts.spotify.com/api/token";
const API = "https://api.spotify.com/v1";
/**
* The token lives HERE — beside the handler, for as long as this isolate does.
* Never in a document: a stored bearer outlives the request that needed it and
* buys nothing a fresh mint does not.
*/
let cachedToken = null;
let cachedUntil = 0;
function json(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
});
}
/** Both halves or neither — one set and one unset is still "not configured yet". */
function credentials(ctx) {
const id = ctx.secrets.SPOTIFY_CLIENT_ID;
const secret = ctx.secrets.SPOTIFY_CLIENT_SECRET;
return id && secret ? { id, secret } : null;
}
/** Client credentials: no redirect, no user login, no callback route. */
async function mintToken(ctx, creds) {
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: creds.id,
client_secret: creds.secret,
});
const res = await ctx.fetch(TOKEN_URL, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
const payload = await res.json().catch(() => null);
if (res.status !== 200 || !payload?.access_token) {
return { ok: false, reason: payload?.error ?? "token-failed", message: payload?.error_description ?? `HTTP ${res.status}` };
}
// Re-mint a minute early: a token that expires mid-request reads as a broken API.
cachedToken = payload.access_token;
cachedUntil = Date.now() + Math.max(0, Number(payload.expires_in ?? 3600) - 60) * 1000;
return { ok: true, token: cachedToken };
}
async function token(ctx, creds, { fresh = false } = {}) {
if (!fresh && cachedToken && Date.now() < cachedUntil) return { ok: true, token: cachedToken };
cachedToken = null;
return mintToken(ctx, creds);
}
/** One call, with exactly one retry when the token turned out to be stale. */
async function callSpotify(ctx, path, params = {}) {
const creds = credentials(ctx);
if (!creds) return { ok: false, reason: "missing-key" };
const url = new URL(`${API}${path}`);
for (const [k, v] of Object.entries(params)) if (v != null) url.searchParams.set(k, String(v));
for (const fresh of [false, true]) {
const minted = await token(ctx, creds, { fresh });
if (!minted.ok) return minted;
const res = await ctx.fetch(url.toString(), {
headers: { authorization: `Bearer ${minted.token}`, accept: "application/json" },
});
if (res.status === 401 && fresh === false) continue; // expired or rotated — mint once more
const payload = await res.json().catch(() => null);
if (res.status === 200 && payload) return { ok: true, body: payload };
if (res.status === 429) {
return { ok: false, reason: "rate-limited", retryAfter: Number(res.headers.get("retry-after") ?? 1) };
}
return { ok: false, reason: String(payload?.error?.status ?? res.status), message: payload?.error?.message ?? `HTTP ${res.status}` };
}
return { ok: false, reason: "unauthorized", message: "Spotify refused the app credentials twice." };
}
export async function fetch(request, ctx) {
const url = new URL(request.url);
if (request.method !== "GET") {
return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
}
// An uncaught throw reaches the app as a bare 500 naming only the error class, so every route answers with a reason.
try {
if (url.pathname === "/search") {
const q = (url.searchParams.get("q") ?? "").trim();
if (q === "") return json({ ok: true, tracks: [], artists: [] });
const result = await callSpotify(ctx, "/search", { q, type: "track,artist", limit: 20, market: "US" });
if (!result.ok) return json(result);
return json({
ok: true,
tracks: (result.body.tracks?.items ?? []).map(trackRow),
artists: (result.body.artists?.items ?? []).map(artistRow),
});
}
if (url.pathname === "/artist") {
const id = url.searchParams.get("id");
if (!id) return json({ ok: false, reason: "no-artist", message: "Pick an artist first." });
const top = await callSpotify(ctx, `/artists/${encodeURIComponent(id)}/top-tracks`, { market: "US" });
if (!top.ok) return json(top);
return json({ ok: true, tracks: (top.body.tracks ?? []).map(trackRow) });
}
return new Response("not found", { status: 404 });
} catch (err) {
ctx.log("error", "spotify route failed", { path: url.pathname, message: String(err?.message ?? err) });
return json({ ok: false, reason: "handler-failed", message: "Something went wrong reading Spotify." }, 500);
}
}
function smallestImage(images) {
const sorted = [...(images ?? [])].sort((a, b) => (a.width ?? 0) - (b.width ?? 0));
return sorted.find((i) => (i.width ?? 0) >= 160)?.url ?? sorted.at(-1)?.url ?? null;
}
function trackRow(t) {
return {
id: t.id,
name: t.name,
artists: (t.artists ?? []).map((a) => a.name).join(", "),
album: t.album?.name ?? null,
image: smallestImage(t.album?.images),
url: t.external_urls?.spotify ?? null,
};
}
function artistRow(a) {
return {
id: a.id,
name: a.name,
genres: a.genres ?? [],
image: smallestImage(a.images),
url: a.external_urls?.spotify ?? null,
};
}
App.jsx sketch
App.jsx
jsximport React, { useState } from "react";
import { Button, Field, Input, Row } from "@vibes.diy/pouf";
const SETUP_STEPS = [
"Open the Spotify developer dashboard and log in.",
"Create an app — any name and description will do.",
"Copy the Client ID from the app's settings.",
"Reveal and copy the Client secret.",
"Set them on this vibe as SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET.",
];
export default function App() {
const [query, setQuery] = useState("");
const [tracks, setTracks] = useState([]);
const [problem, setProblem] = useState(null);
const search = async () => {
const res = await fetch(`/_api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
if (!data.ok) {
setProblem(data);
return;
}
setProblem(null);
setTracks(data.tracks);
};
return (
<div>
<Row gap={2} align="top">
<Field label="Search">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={query} onChange={setQuery} placeholder="Search Spotify" />}</Field>
<Button type="button" onClick={search}>Search</Button>
</Row>
{problem?.reason === "missing-key" && (
<section>
<h2>Add your Spotify app credentials</h2>
<ol>
{SETUP_STEPS.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<p>Both values stay on the server and are never sent to the browser.</p>
</section>
)}
{problem?.reason === "rate-limited" && <p>Spotify asked us to wait {problem.retryAfter} seconds.</p>}
{problem && !["missing-key", "rate-limited"].includes(problem.reason) && <p role="alert">{problem.message}</p>}
<ul>
{tracks.map((t) => (
<li key={t.id}>
{t.image && <img src={t.image} alt="" width={64} height={64} />}
<a href={t.url} target="_blank" rel="noreferrer">
{t.name}
</a>{" "}
— {t.artists}
</li>
))}
</ul>
<section>
<h3>What this page can see</h3>
<p>
Spotify's public catalog — search, artists, albums and tracks. Your own top tracks, playlists and saved songs
belong to your Spotify account and are not part of this.
</p>
</section>
</div>
);
}
Store what the person keeps — a shortlist, a rating, a note on a track — in the app's own database keyed by the Spotify track id. The catalog is Spotify's and gets re-read; the opinions about it are the app's and get saved.