Loading
All posts

3 min read#Next.js#APIs#Spotify

Putting live GitHub and Spotify data on my portfolio

How the contribution heatmap and the now-playing cassette on this site work, and the one Spotify OAuth gotcha that cost me an evening.

A portfolio is a static thing by nature. You write it once and it slowly goes out of date. I wanted two small pieces of mine to stay alive on their own: a GitHub contribution graph, and a cassette tape whose reels spin when I'm listening to something on Spotify.

Both turned out to be small Next.js route handlers plus a client component. Here is how each one works.

The contribution heatmap

GitHub doesn't expose the contribution calendar over its REST API, but the GraphQL API has exactly what I needed:

query($login: String!) {
  user(login: $login) {
    contributionsCollection {
      contributionCalendar {
        totalContributions
        weeks {
          contributionDays { date contributionCount contributionLevel }
        }
      }
    }
  }
}

The route handler at /api/github runs that query with a personal access token. A classic token with no scopes ticked is enough, because contribution data is public. That also means the token can't do any damage if it ever leaks.

A few details that mattered:

  • Caching. Contributions don't need to be live to the second, so the route revalidates once an hour. That keeps me far away from any rate limit.
  • A fallback. If the token is missing or GitHub is having a bad day, the route falls back to a public, token-free mirror. The section never just breaks.
  • Levels, not counts. GitHub already buckets each day into quartiles (NONE to FOURTH_QUARTILE). I map those to levels 0 to 4 and colour them by mixing my accent colour into the muted background with color-mix(), so the graph follows the light and dark themes for free.

The grid itself is plain divs, not an image. That means one shared tooltip on hover, a skeleton while loading, and horizontal scrolling on phones that starts at the most recent week.

The now-playing cassette

Spotify was more interesting, because it needs OAuth, and a portfolio has no user sitting there to log in.

The trick is the refresh token. You go through the OAuth flow once, by hand, and Spotify gives you a long-lived refresh token. On every request the server swaps it for a short-lived access token:

const res = await fetch("https://accounts.spotify.com/api/token", {
  method: "POST",
  headers: {
    Authorization: `Basic ${base64(clientId + ":" + clientSecret)}`,
    "Content-Type": "application/x-www-form-urlencoded",
  },
  body: new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token: process.env.SPOTIFY_REFRESH_TOKEN!,
  }),
});

With that access token the route calls currently-playing. If that comes back empty (a 204, or a podcast), it falls back to recently-played, so the widget always has something to show.

On the client, the track lands on a cassette sitting on my desk: the title is handwritten on the label, the album art is stuck on like a sticker, and the reels spin while the song plays. As it plays, the tape visibly winds from one reel onto the other. It polls every 30 seconds, but only while the tab is visible, and it advances the progress locally between polls so it doesn't look jumpy.

The gotcha: localhost is not allowed

To get the refresh token in the first place I wrote a tiny dev-only route that redirects to Spotify's consent screen and prints the token when it comes back. It failed with "redirect URI not configured", even though I had added the URI in the dashboard.

Two things were going on:

  1. Spotify no longer accepts localhost redirect URIs. You have to use the loopback IP, http://127.0.0.1:3000/....
  2. Even when I opened the page on 127.0.0.1, Next.js reported request.nextUrl.origin as localhost in dev. So the app was sending Spotify a redirect URI it had never seen.

The fix was to build the redirect URI from the Host header and force the loopback IP. If you are building the same thing and Spotify keeps rejecting you, check which redirect URI your app is actually sending. It's in the redirect_uri query parameter of the authorize URL.

Was it worth it?

For two widgets, yes. They're the parts of the site that change without me touching them, and they say more about how I work than another paragraph of copy would. If you want to build your own, the source is on GitHub.