You know those little status chips people have on their websites? The ones showing what's playing on Spotify, how much they've coded this week, or some other tiny slice of live activity?
I've wanted them on my site for years.
What stopped me wasn't the UI. It was the integrations. Every service has its own authentication model, response format, refresh cadence, and collection of edge cases. Spotify wants OAuth. WakaTime exposes public share URLs. GitHub prefers GraphQL. None of them agree on anything.
The interesting part turned out not to be the chips themselves, but the layer sitting underneath them: a small provider system that takes three completely different APIs and presents the rest of the application with one consistent shape.
They're genuinely live. Spotify refreshes roughly every 30 seconds, so if you catch me mid-song, that's actually what's playing.
Here's the lineup:
Spotify → what's currently playing, or the last thing I listened to. WakaTime → coding time for the current week. GitHub → commits pushed over the last seven days. Vercel → static, because their token model isn't something I want exposed to a public-facing feature. Figma → also static. I'd love to claim I live in Figma, but most of my "design" happens straight in code.
The first three are fully live. The last two are honest cardboard cut-outs.
The structure
Everything lives under src/modules/external/, sliced three ways:
external/
shared/ # types + config — safe on both sides of the wire
server/ # providers, registry, zod schemas — secrets live here
client/ # the SWR context that feeds the chipsThe separation is simple:
- shared defines the contracts.
- server deals with third-party APIs.
- client renders whatever comes back.
The base class
Every provider extends the same abstract class, which hoards all the tedious, error-prone bits so nobody has to reinvent them:
export abstract class StatusProvider<T> {
abstract readonly name: string;
abstract readonly cacheSeconds: number;
abstract getStatus(): Promise<T>;
protected async getJson<S>(url: string, schema: ZodType<S>, config?: AxiosRequestConfig) {
try {
const res = await axios.get(url, config);
const parsed = schema.safeParse(res.data);
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
}Two small decisions are pulling real weight here. safeParse means that when a third party ships back garbage "and which they will sometimes" the chip quietly degrades to null and shows its offline face instead of handing you a 500. And every provider declares its own cacheSeconds, which becomes the cache policy at the edge. Spotify says 15; WakaTime and GitHub say 1800. The provider knows how fresh it needs to be, so the provider gets to decide.
One route to rule them all
There's a single dynamic route — app/api/[provider]/status/route.ts — and it's almost entirely plumbing:
export async function GET(_req: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider } = await params;
if (!isProviderName(provider)) {
return Response.json({ error: "Unknown provider" }, { status: 404 });
}
const source = providers[provider];
const data = await source.getStatus();
return Response.json(data);
}isProviderName is a type guard over a registry — just a plain object mapping names to instances:
export const providers = {
spotify: new SpotifyProvider(),
wakatime: new WakaTimeProvider(),
github: new GitHubProvider(),
} as const;Adding a fourth chip is gloriously boring: write a class that extends StatusProvider, drop it in that object, done. The route, the caching, the validation, and the client all keep working without a single edit. /api/steam/status would simply… exist.
Spotify → the Lee Robinson way
Spotify is the diva... the only one that needs real auth. The trick is the one Lee Robinson popularised → you log in once, by hand, to mint a long-lived refresh token, and stash it as a secret. After that the server quietly swaps the refresh token for a short-lived access token whenever it needs one. No login flow, no popups, no user ever involved again.
private async accessToken(): Promise<string | null> {
const { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REFRESH_TOKEN } = env;
if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET || !SPOTIFY_REFRESH_TOKEN) return null;
if (this.token && this.token.expiresAt > Date.now() + 60_000) return this.token.value;
const basic = btoa(`${client_id}:${client_secret}`);
const res = await axios.post(
TOKEN_URL,
new URLSearchParams({ grant_type: "refresh_token", refresh_token: SPOTIFY_REFRESH_TOKEN }),
{
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
},
);
// …parse, cache { value, expiresAt }, return value
}The access token gets cached in memory with a 60-second safety buffer, so most requests skip the token dance entirely. The status read itself is a tiny state machine: ask currently-playing (which answers 200 when something's on and 204 when it isn't), and if it's quiet, fall back to recently-played:
async getStatus(): Promise<NowPlaying> {
const token = await this.accessToken();
if (!token) return OFFLINE;
try {
const res = await axios.get(CURRENT_URL, {
headers: { Authorization: `Bearer ${token}` },
validateStatus: (s) => s === 200 || s === 204,
});
if (res.status === 200) {
const parsed = SpotifyCurrentSchema.safeParse(res.data);
if (parsed.success && parsed.data.is_playing && parsed.data.item) {
return this.normalize(parsed.data.item, "playing");
}
}
return this.recent(token);
} catch {
return OFFLINE;
}
}Every exit returns a NowPlaying — { state: "playing" | "recent", … } or { state: "offline" }. The chip renders a discriminated union, so there's no loading forever and no undefined.title landmine waiting in the view.
Don't ask for more scopes than you need. These chips only read playback, so
user-read-currently-playing and user-read-recently-played are plenty. A leaked token can only
do what its scopes allow — so give it as little to do as possible.
The cheap dates
WakaTime and GitHub are refreshingly low-maintenance. WakaTime skips auth altogether — it exposes a public share URL that hands back the last 7 days as JSON, so the provider just adds up the seconds and formats them:
const total = data.data.reduce((sum, day) => sum + day.grand_total.total_seconds, 0);
return { ok: true, label: formatDuration(total) }; // "12h 30m"GitHub prefers GraphQL — contributionsCollection.totalCommitContributions gives an exact weekly count — but it degrades to the public events feed (counting PushEvents) when there's no token, so it still shows something in local dev before you've wired up secrets. Same base class, same { ok: true | false } shape, zero drama.
Polling, on the (deliberately dim) client
The browser side is intentionally dumb as a brick. One context provider runs three SWR subscriptions against the routes, each on its own clock:
const { data: nowPlaying } = useSWR<NowPlaying>("/api/spotify/status", fetcher, {
refreshInterval: POLL.nowPlaying, // 30s
});
const { data: coding } = useSWR<CodingStats>("/api/wakatime/status", fetcher, {
refreshInterval: POLL.stats, // 30min
});
const { data: commits } = useSWR<GitHubActivity>("/api/github/status", fetcher, {
refreshInterval: POLL.stats,
});SWR handles dedup, focus revalidation, and retries; the chips just read from context and render. The poll cadence and the edge cacheSeconds are set independently, on purpose — Spotify polls at 30s against a 15s cache so a track change shows up fast, while the stats poll every 30 minutes against a 30-minute cache because nobody on earth needs their weekly coding hours updating in real time.
What the whole song-and-dance buys
Strip away the specifics and the shape is almost embarrassingly simple:
shared/types are the common language — oneNowPlaying, validated on the way in, rendered on the way out.server/providers soak up each API's mess and hand back a clean union, never throwing.- One route + a registry turn "add an integration" into "write a class."
- The client knows exactly one trick: poll a URL.
None of these pieces is clever on its own — and that's the point. A flaky Spotify response, a missing GitHub token, and a malformed WakaTime payload all fail the same boring way: the chip shrugs, shows its resting state, and the page never even notices anything went wrong.