DEV Community

Cover image for A Live Spotify Card Inside a Static README: How I Built It and Why It Works
Gautam Vhavle
Gautam Vhavle

Posted on

A Live Spotify Card Inside a Static README: How I Built It and Why It Works

How a URL that pretends to be an image makes your GitHub profile feel alive


My Profile Was Blank While Everyone Else's Was Alive

I'd been meaning to add something creative to my profile for a while, but I wanted it to be me, not another "hello world" or a tech-stack list. So I waited for an idea worth building. Then it hit me: my favorite hobby is listening to music, and Spotify already has an API. What if my profile could show what I'm playing right now?

I knew Markdown, I knew APIs, I'd heard of OAuth. How hard could a live image be?

Turns out, GitHub READMEs don't work like normal web pages.

I built spotify-readme-card to make that idea real, and put a live playground at gautamvhavle.github.io/spotify-readme-card where you can preview and copy your card before you even deploy. It's a tiny function that pretends to be an image. Every time someone views your profile, GitHub asks that function "what's playing?", the function asks Spotify, and it draws a fresh card on the spot. No database, no daily commits, no extra apps.

I tried three obvious ways first. All three failed for the same reason. The fourth worked because it stopped trying to make Markdown dynamic, and made the image dynamic instead.

spotify-readme-card

Now my Github profile looks much live ✨


Why a README Can't Be Live

If you've ever tried to make a README do something clever, you've hit the same wall I did.

GitHub takes your Markdown, turns it into HTML, then strips out anything that could run code: no script, no iframe, no click handlers. Nothing executes. Ever.

That leaves you with one thing that can be dynamic: an external image.

<img src="https://live-spotify-readme-card.vercel.app/?theme=dark" width="420" />
Enter fullscreen mode Exit fullscreen mode

When GitHub sees that, it doesn't leave your URL as-is. It rewrites it through its own image proxy called camo: think of it as a bouncer and photocopier. Camo checks that the image is safe, makes a copy, and serves that copy to viewers. It also caches that copy for a few minutes (the exact timing isn't published, this is what I've observed), even if your server says "don't cache."

That caching is the whole puzzle. Your function can draw a fresh card on every request, but viewers will see camo's cached copy for a few minutes. The direct URL is always live. The README trails behind by a few minutes.

So the real question is: if the only live piece is an image that gets cached for a few minutes, how do you make it feel fresh, sharp, and reliable?

You can test the live part right now, no README needed:

curl https://live-spotify-readme-card.vercel.app/ > card.svg && open card.svg
Enter fullscreen mode Exit fullscreen mode

That hits your function directly and gives you a fresh card instantly.


spotify-readme-card

The Three Dead Ends Before the One That Worked

Before the image-as-function trick, I looked at the obvious options. Each one taught me why the final design had to be different.

1. A GitHub Action that commits a new image every 10 minutes. It works, but it fills your git history with "update now playing" commits, it's always a few minutes stale, and you need extra permissions just to update your own profile.

2. JavaScript inside Markdown. You paste a <script> that fetches Spotify and updates the page. GitHub strips it before anyone sees it. It never runs. This is the most common "wait, why doesn't this work?" moment if you come from web development.

3. A GIF that a server renders. GIFs are big, blurry on high-res screens, and you still need a server. You lose the sharpness that makes a small card look good.

The click moment was simple: the README doesn't need to contain live code. It just needs to point at live code.

Turn the image URL into a function call. Instead of committing an image, you deploy a function that is an image. Every time GitHub asks for it, the function runs and returns a fresh drawing.

Suddenly: live, sharp, animated, and no repo writes. The README never runs your code; it just re-fetches your image. So make the image be the code.

camo

Follow the arrows left to right, then back. Viewers don't talk to your server directly; they talk to camo, and camo talks to your server. That extra step is why caching and image format matter so much.


What It Actually Is: A URL That Draws a Picture

Here's how I think about it now:

Instead of committing an image file, you deploy a tiny program that returns an image. Every time camo asks for your URL, that program:

  1. Reads the request: what theme, width, and colors you asked for
  2. Gets permission from Spotify: quietly, using a saved refresh token
  3. Asks what's playing: what's on now, or what you played last
  4. Grabs the album art: and bakes it into the image so camo doesn't need to fetch it separately
  5. Draws the card: text, art, and a little animation, all as code
  6. Sends it back as an image: so GitHub is happy to show it

It's a function call disguised as an image tag. The browser thinks it's fetching a static file. It's actually running a program.

The key insight: the card is an SVG, and SVG is just text. You don't need a heavy image library or a headless browser. You can build the whole card by stitching strings together. That keeps the function tiny, fast, and free to run, which matters when it runs on every profile view.


Three Rules That Shaped Everything

Every interesting decision came from a constraint. Three of them did most of the designing.

1. GitHub's image proxy caches everything for a few minutes

I learned this the hard way. My function was drawing fresh cards, but the README looked frozen for over 10 minutes. The direct URL was live; the README was stale.

The fix has two parts: send clear "don't cache" headers anyway (they help other caches), and, more importantly, tell camo to drop its copy every few minutes. The project does this with a small script that finds the cached image URLs and tells camo to drop them, run automatically every 10 minutes. It's not instant, but it keeps the README within a few minutes of reality.

What this means for you: expect a few minutes of delay in the README (I've seen it stretch past 10). The direct URL is always instant. That's just how GitHub's proxy works.

2. Spotify's login is a master key and a temporary badge

Spotify uses a common pattern: you log in once, get a refresh token that lasts six months (think of it as a master key you lock in a safe; it expires, so you'll re-run the login every so often), and then your server trades that for a short-lived access token (a temporary badge that lasts about an hour) whenever it needs to ask "what's playing?"

You store the master key safely on your server, and the function quietly mints a fresh badge every hour. Viewers never see either; it's all server-side.

One gotcha I hit: you need to ask for two permissions (what's playing now and what you played recently; the second is the fallback when nothing's playing). The login redirect also has to match Spotify's settings exactly; I lost an afternoon to that one (full story in the mistakes below).

spotify-readme-card

Think of the top bar as the master key in a safe, and the bottom blocks as hourly badges you print from it. Viewers never see either.

3. Inside an image, only some things work

An SVG inside an <img> tag is a limited world: CSS animations work (so the equalizer can bounce and long titles can scroll), but JavaScript doesn't run at all, and remote images don't reliably load: camo fetches the outer image, not the inner one. That's why the album art gets baked in as text (more on that below).

The function itself runs on Vercel as a serverless function: no database, no server to manage, just code that wakes up, draws, and goes back to sleep. One account per deployment, comfortably within the free tier.


How It All Fits Together

Here's the full journey in one line, the same line the code follows:

README image → GitHub's proxy → your function → Spotify → bake in artwork → draw SVG → show card
Enter fullscreen mode Exit fullscreen mode

Each step is small on its own. The README points at your URL. The proxy checks the image is safe and serves a cached copy or asks your function. Your function wakes up, reads your theme and size preferences, quietly gets permission from Spotify, asks what's playing (or what you played last), bakes the cover into the image, draws the card, and sends it back as image/svg+xml: always a valid image, never a broken icon.

The only delay is the proxy's cache. It holds its copy for a few minutes, and the auto-refresh script nudges it every 10, so the README trails the direct URL by a few minutes at most. Everything else is live on every request.

spotify-readme-card

If you get this flow, you get the whole project: README → proxy → function → Spotify → image.


The Smallest Card That Actually Works

The core logic is surprisingly short: about 30 lines. Everything else (themes, scrolling titles, bouncing bars) is polish on top.

// Simplified: see api/_lib/handler.ts for the real thing
export default async function handler(req, res) {
  const options = parseOptions(req.query, "main");
  const track = await resolveTrack(); // gets permission, asks Spotify
  const art = track ? await fetchArtwork(track.artworkUrl) : null;
  const svg = track
    ? renderMainCard(track, art, options)
    : renderMessageCard(options, "Nothing playing", "No recent activity");

  res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
  applyNoCache(res);
  res.status(200).send(svg);
}
Enter fullscreen mode Exit fullscreen mode

What it does, in plain English:

  • Checks the request: makes sure it's a normal page view, reads your theme and size preferences (bad colors fall back to the theme, sizes get clamped to a sensible range)
  • Finds the music: quietly gets permission from Spotify, asks what's playing now, falls back to what you played last
  • Grabs the art: fetches the album cover and bakes it in, or uses a placeholder if it can't
  • Draws the card: builds the SVG with backdrop, art, title, artist, and little animations
  • Always returns an image: even if Spotify is down, you get a nice card that says "Spotify unavailable" instead of a broken image icon

Hit your deployed URL directly and you get a live card instantly. Embed it in a README and you get the same card, just a few minutes behind due to caching.

What just happened?

Same journey as the map above, compressed into one function:

  1. Options: the URL settings become safe values (bad colors fall back to the theme, sizes get clamped)
  2. Permission: reused while it's fresh, traded for a new badge only when needed
  3. Music: "now playing", or "last played" if nothing's on
  4. Art: the cover gets baked in as text
  5. Response: the SVG goes out as an image

Result: a working card in about 30 lines of core logic.

Limitation: no scrolling for long titles, no bouncing bars, no themes; those are the next layer, nice to have but not needed for a working card.

Lesson: the 30-line loop is the whole project. Everything else is decoration.


Making It Survive GitHub

Getting a card to show is easy. Getting a card that always shows, with any song, any cover, any theme; that's the hard part. I hit it in two places.

The album art that never loaded

My first version did the obvious thing:

<image href="https://i.scdn.co/image/ab67616d..." width="96" height="96" />
Enter fullscreen mode Exit fullscreen mode

It worked when I opened the SVG directly. Blank square in the README. GitHub's proxy fetched the outer image but never fetched the inner one.

Fix: bake the art right into the SVG:

<image href="data:image/jpeg;base64,/9j/4AAQSkZJRg..." width="96" height="96" />
Enter fullscreen mode Exit fullscreen mode

Base64 is just a way to write the image's bytes as letters and numbers, so the whole cover fits inside the SVG as text. Now the image is self-contained: no extra fetching needed. The function also makes sure the art comes from Spotify's servers and caches it briefly so it doesn't re-fetch on every view.

spotify-readme-card

If you take one thing from this section: bake the art in, or it won't show.

The title that didn't fit

Once art worked, long titles broke the layout. "The Rise and Fall of Ziggy Stardust and the Spiders from Mars (2012 Remaster)" doesn't fit in 420 pixels.

You can't measure text the normal way inside an image: there's no layout engine to ask "how wide is this?" So the project uses a simple trick: a small table that estimates how wide each character is, adds them up, and checks if the title fits.

  • Fits? Show it normally.
  • Too long? Gently scroll it like a marquee: pure CSS, no JavaScript needed. It slides, pauses, and loops with a small gap so you can read the whole title.

For a card with two lines of text, this estimate is good enough. A full layout engine would be more accurate but much heavier, and not worth it here.

spotify-readme-card

The polish that makes it feel alive

With the hard parts solved, the rest is what turns "works" into "delightful":

spotify-readme-card

you can customise it in playground

  • Bouncing bars: four little bars that dance when music is playing, stay still when it's your last played track. A subtle signal that feels alive.
  • Soft backdrop: a blurred, faded copy of the album art behind the card, so even a white cover doesn't wash out the design.
  • 11 themes: each is just six colors (background, surface, text, muted text, accent, border). You can also override any of them with ?bg=...&accent=... in the URL.
  • Three layouts: a detailed card, a compact one, and a portrait, with adjustable width and corner rounding.

My advice: get the basic card working first, then add the polish. The bouncing bars are fun, but they only matter after the card reliably shows up. And when you're ready to tune it, the playground shows every theme and layout as a live preview, so you can tweak and copy your card before you ever deploy.

spotify-readme-card

Each theme is just six colors, and that simplicity is what keeps theming safe and easy.


Which Path Should You Take?

There are a few reasonable ways to put a Spotify card on your profile. Here's how they compare in plain terms:

Approach How fresh Cost How hard Best for
This project (Vercel + SVG) Live, ~2–5 min delay Free Easy First try, one account
Cloudflare Workers Same Free (3M/mo) Medium High traffic, edge speed
Self-hosted server Same (~2–5 min via camo) ~$5–12/mo Hard Serving many users
GitHub Action (commits image) 10–15 min delay Free Medium No server at all

Prices as of August 2026.

A few tradeoffs worth knowing:

SVG vs PNG/GIF. SVG is tiny, stays sharp on any screen, and can animate with CSS. PNG needs heavier tools and can't animate without becoming a GIF. For a small card, SVG is the sweet spot.

One account vs many. This project is built for one Spotify account per deployment: no database, no logins for visitors. That's why it fits so neatly on the free tier. If you want one deployment to serve many users, you'll need a database and a different setup. Don't build that unless you need it.

My recommendations:

  • Just want it working? → This project. Clone, log in once, deploy, paste the image tag. Live card in 10 minutes.
  • Expect lots of views? → Cloudflare Workers. Same trick, but built for high scale.
  • Really don't want a server? → GitHub Action every 15 minutes. Accept that it'll be a bit stale and your git history will be noisy.

What Breaks in Production (and Why You Still See a Card)

The most important rule in this project: never show a broken image.

Every image URL always returns a valid card, even when things go wrong. Spotify down? You get a card that says "Spotify unavailable." Nothing playing? "Nothing playing. No recent activity." Missing setup? Same: a styled card, not an error page. Only the debug URL (/json) returns real error codes.

That one rule is what makes the README feel reliable even when the upstream isn't.

Beyond that, a few lessons from running it:

Caching is layered. Your function caches the Spotify permission for an hour and the album art briefly. GitHub's proxy caches the final image for a few minutes. The proxy is the one you can't control; that's why the auto-refresh every 10 minutes exists.

Security is simple checks. Colors from the URL are validated, text is escaped so it can't break the image, and album art is only fetched from Spotify's servers. Error details stay in server logs, never in the image viewers see.

Cost is boring (in a good way). On Vercel's free tier, a profile with tens of thousands of views a month is a rounding error. No database, no heavy dependencies; nothing to scale until you're serving many users.

Debugging has a shortcut. Hit /json instead of the image URL and you get the raw track data as JSON with real error messages. Pair that with Vercel's logs and you can diagnose most issues without touching the card.

spotify-readme-card

A broken image feels like your profile is broken. A styled fallback card feels intentional; viewers can't tell the difference between "nothing playing" and "Spotify is napping."


Three Ways I Broke It So You Don't Have To

⚠️ The refresh token that stopped working

What happened: Card flipped to "Spotify unavailable" overnight.
Why: Spotify refresh tokens expire after six months, and mine had also been revoked by a password change. The docs are explicit: don't assume a refresh token stays valid.
Fix: Ran npm run authorize again, copied the new key into Vercel, redeployed. Took two minutes.
Lesson: Treat the key like a password that will expire. Re-run the login every six months or so, and keep the script working.

⚠️ The color that broke the card

What happened: A custom color in the URL produced a broken, unreadable card.
Why: The URL is user input, and the card is XML. Unchecked input in XML is a recipe for breakage, and worse in other contexts.
Fix: Only accept proper hex colors, escape all text, and fall back to the theme on anything invalid.
Lesson: Validate at the edge, escape at the render. Even when a context seems safe, the next one might not be.

⚠️ The localhost that wasn't 127.0.0.1

What happened: The login step kept failing with a redirect mismatch. Everything looked right.
Why: I typed http://localhost:5175/callback; Spotify's dashboard had http://127.0.0.1:5175/callback. They look identical to us, but Spotify does an exact string match.
Fix: Copy-paste the exact URI everywhere: dashboard, script, config.
Lesson: When a spec says "exactly," it means exactly. Don't retype it.


What I Wish Someone Had Told Me on Day One

Five insights that would have saved me the most time:

  1. The README is static but the image URL isn't. Stop trying to make Markdown dynamic. Make the image dynamic. One <img> tag is the whole trick.

  2. The proxy is the bottleneck, not Spotify. Spotify usually answers in well under a second. GitHub's cache adds minutes. Design your expectations around the cache, not your function.

  3. Don't rebuild when the README looks stale. For a few minutes after every change, the proxy is still serving the old copy; that's not your code. Wait, or run the refresh script.

  4. The polish is where the time goes. The scrolling title took longer than the whole card did. Start plain, then add the fun.

  5. Always return a card. Even on failure, return a valid image. A graceful fallback beats a broken icon every time. Reliability is a rendering decision.

Bottom line: the constraints designed the solution. GitHub's proxy said "bake your images." SVG-in-image said "use CSS, not JavaScript." Spotify said "one master key that expires, hourly badges." Serverless said "keep it tiny." Follow those and the architecture draws itself.


Your Next Move

🌱 Just want it working?

  1. Open the playground and preview the card with your theme and layout before you build anything.
  2. Clone the repo and run npm run authorize: it opens Spotify, handles the login, and gives you the key.
  3. Deploy to Vercel, add the three keys it asks for, and open your URL to see a live card.
  4. Paste the <img> tag into your profile README and watch it come alive. Try ?theme=tokyonight to see theming work.

🚀 Want to make it yours?

  1. Pick a theme or build your own with ?bg=...&accent=...: six colors, that's it. The playground's custom palette does this visually and hands you the ready-made URL.
  2. Adjust width and radius to match your profile layout. Long titles scroll automatically; no extra setting needed.
  3. Hit /json to see the raw data and /small for the compact layout.

⚡ Want to go further?

  1. Add a theme: it's just six colors in one file, plus a quick test.
  2. Add a new layout: the drawing is just strings, so a new layout is a new function, not a new dependency.
  3. Only build multi-user support if you really need it; you'll need a database and a different hosting plan.

Quick checklist:

  • [ ] Understand why READMEs are static and what the proxy does
  • [ ] Run npm run authorize and get your key
  • [ ] Deploy and see a live card at your URL
  • [ ] Embed in README and notice the few-minute cache delay
  • [ ] Try a theme and adjust the width
  • [ ] Hit /json for debugging

Resources Worth Bookmarking

The project:

  • spotify-readme-card: start with the README, then peek at api/_lib/spotify.ts (login + playback) and api/_lib/artwork.ts (art handling).
  • Live playground: preview every theme and layout, tweak the custom palette, and copy the ready-made image URL or Markdown.
  • Live demo: the deployed card itself, plus /json for the raw track data.

Spotify:

GitHub:

Hosting:


Final Thoughts: Constraints Are the Design

A few weeks ago, my profile was blank. I knew the pieces (Markdown, OAuth, APIs), but I didn't see how they fit through GitHub's keyhole. Every obvious approach failed for a reason that taught me something: the cron job taught me about staleness, the stripped script taught me about sanitization, the blank square taught me about the proxy.

The biggest lesson? I didn't design around the constraints. They designed it for me. Each one vetoed the easy answer and revealed the shape of the solution.

Your first version won't be perfect. Mine wasn't: it had a blank square where the art should be and a title that overflowed the card. But it was live. And live is the whole point.

Written August 2026. Prices, limits, and API behavior change over time; check the linked docs for the latest.

If you build one, I'd love to see it. What surprised you? What theme did you pick? What broke? Drop a note; let's learn from each other's cards.

One specific first move: run npm run authorize. Get the key. Everything else follows from there.

Top comments (0)