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. The setup item: the owner's Spotify app credentials
The owner registers an app with Spotify, once. The Setup surface the item
lands on — the owner-only entry in the app's navigation, the verdict from
/setup, the re-read on focus that unlocks search in place — is the use-vibe
docs' Some apps need setup before they work; this recipe contributes one
item to it. The Spotify item is the credential pair: the backend answers missing-key while either ctx.secrets.SPOTIFY_CLIENT_ID or ctx.secrets.SPOTIFY_CLIENT_SECRET is absent, and its steps link the Spotify developer dashboard by URL and name both secrets in so many words.
The steps are written for someone who has never opened the dashboard, so each
one is a link to the exact page and uses the words that page uses:
- Open the Spotify developer dashboard: https://developer.spotify.com/dashboard (log in with a Spotify account). Press Create app — any name and description; for Redirect URIs put the app's own address, since this flow never uses one — and save.
- Open the app you made and press Settings. Copy the Client ID, then press View client secret and copy that too.
- Open this vibe's Settings tab on vibes.diy:
https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings(that is My Vibes → this vibe → Settings). Under Secrets, enterSPOTIFY_CLIENT_IDandSPOTIFY_CLIENT_SECRET, paste each value, and save. From a terminal the same thing isnpx vibes-diy secrets set SPOTIFY_CLIENT_ID --vibe <ownerHandle>/<appSlug>and the same forSPOTIFY_CLIENT_SECRET. - Come back to the app. Search works the moment both are saved; no redeploy.
Those steps are the item's copy on the Setup surface, with the vibe's real
handle and slug filled in from the ownerHandle and appSlug the /setup
route hands over. Two secrets means two ways to be half-configured: one item,
done only when both are present, so one set and one unset reads as To do
rather than as a broken API. In the consumer flow, a search whose pair is not
set reads Search is not open yet; the owner's Setup entry is where the
steps are, and later it is where a rotated secret goes.
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. Say so, and point the owner at the Setup entry: the item reads Done because both values are present, and one of them is wrong.401from the Web API — mint a fresh token and retry once; if the second try is also 401, say so and point the owner at the Setup entry the same way.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 {
// The setup verdict: ready for everyone, the item (and the handle + slug the Setup screen
// links with) for the owner alone.
if (url.pathname === "/setup") {
const owner = ctx.userInfo?.userHandle === ctx.appInfo.ownerHandle;
const items = [{ id: "spotify-app", label: "Spotify app credentials", done: credentials(ctx) !== null }];
const ready = items.every((item) => item.done);
if (!owner) return json({ ok: true, ready, owner });
return json({ ok: true, ready, owner, items, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
}
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, { useEffect, useRef, useState } from "react";
import { apiFetch } from "use-vibes";
import { Badge, Button, Field, Heading, Input, Row, Stack, Text } from "@vibes.diy/pouf";
// The setup verdict, read on mount and again whenever the app comes back into view: the owner
// sets the pair in another tab and returns, and search unlocks without a reload.
function useSetup() {
const [setup, setSetup] = useState(null); // { ready, owner, items?, ownerHandle?, appSlug? }
const latest = useRef(0);
useEffect(() => {
// Mount, focus and visibility reads overlap; only the latest one may land, or a slow stale
// answer overwrites a fresh one.
const load = () => {
const n = ++latest.current;
apiFetch("/setup").then((r) => r.json()).catch(() => ({ ready: false, owner: false })).then((s) => { if (n === latest.current) setSetup(s); });
};
load();
const onVisible = () => { if (document.visibilityState === "visible") load(); };
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("focus", load);
return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); };
}, []);
return setup;
}
// The item's steps: each one a link to the exact page, in that page's own words, with this
// vibe's real handle and slug filled in.
function setupSteps(vibe) {
return [
{ href: "https://developer.spotify.com/dashboard", link: "Spotify developer dashboard", text: ": log in, press Create app (any name; put this app's own address under Redirect URIs), and save." },
{ href: "https://developer.spotify.com/dashboard", link: "your app's Settings", text: ": copy the Client ID, then press View client secret and copy that too." },
{ href: `https://vibes.diy/vibes/mine/${vibe}/settings`, link: "this vibe's Settings tab", text: `: under Secrets, enter SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET, paste each value, and save — or run npx vibes-diy secrets set SPOTIFY_CLIENT_ID --vibe ${vibe} and the same for SPOTIFY_CLIENT_SECRET.` },
];
}
// The Setup surface, the owner's alone.
function SetupScreen({ setup }) {
const item = setup.items.find((i) => i.id === "spotify-app");
return (
<Stack>
<Heading>Setup</Heading>
<Row gap={2}>
<Text>{item.label}</Text>
<Badge tone={item.done ? "up" : "info"}>{item.done ? "Done" : "To do"}</Badge>
</Row>
<ol>
{setupSteps(`${setup.ownerHandle}/${setup.appSlug}`).map((step) => (
<li key={step.link}>
Open <a href={step.href} target="_blank" rel="noreferrer">{step.link}</a>
{step.text}
</li>
))}
</ol>
<Text muted>Both values stay on the server and are never sent to the browser. Search works the moment they are saved.</Text>
</Stack>
);
}
export default function App() {
const setup = useSetup();
const [screen, setScreen] = useState("search");
const [query, setQuery] = useState("");
const [tracks, setTracks] = useState([]);
const [problem, setProblem] = useState(null);
const ready = setup?.ready === true;
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 (
<Stack>
<Row gap={2}>
<Button variant="quiet" onClick={() => setScreen("search")}>Search</Button>
{/* The Setup entry: the owner's alone, highlighted while the item is still to do, quiet once done. */}
{setup?.owner && (
<Button variant={ready ? "quiet" : "solid"} onClick={() => setScreen("setup")}>
Setup{ready ? "" : " · to do"}
</Button>
)}
</Row>
{screen === "setup" && setup?.owner ? (
<SetupScreen setup={setup} />
) : (
<Stack>
{ready ? (
<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>
) : (
<Text muted>Search is not open yet.</Text>
)}
{problem?.reason === "rate-limited" && <Text>Spotify asked us to wait {problem.retryAfter} seconds.</Text>}
{problem && problem.reason !== "rate-limited" && <Text role="alert">{problem.message}</Text>}
<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>
</Stack>
)}
</Stack>
);
}
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.