Rethinking the GitHub card, and a 403 that build success didn't catch
The homepage has a small “On GitHub” section: a shared-packages grid, a stats card, and four pinned-repo cards. The stats and pinned-repo cards were images, hotlinked from a third-party stats service on Vercel, on every single homepage visit. No consent gate, no opt-out. That’s the kind of thing that’s easy to add and easy to forget is there.
A UI/UX pass caught it. The site’s own privacy page says nothing tracks you until you say yes, and here were five requests firing on page load, every time, handing a stranger’s IP and user agent to a service I don’t control. When that service is down, the section just goes blank. No error, no fallback, just quiet empty space where a card used to be.
The part that made this more than a delete
Before touching anything, I pulled up what those four pinned repos actually were: homelab-tools, astro-blog, astro-affiliate, astro-opt-in-analytics. Three of those four already had their own cards, written by hand, localized into Spanish, sitting in the “Shared packages” grid directly above the images I was about to replace. A straight image-to-card swap would’ve kept showing astro-blog twice on the same page, once with real curated copy and once with whatever English description GitHub’s API handed back.
So the fix wasn’t “swap five images for five native cards.” It was: keep the shared-packages grid as the one true home for those three repos, and figure out what the rest of the section is actually for. I ended up dropping per-repo cards entirely and replacing the whole stats/pin block with one line: public repo count and follower count, pulled from GitHub’s REST API, sitting right next to the existing profile link.
Fetching this at build time, not runtime
Both the English and Spanish homepages are fully prerendered (export const prerender = true), so a fetch() call in the component’s frontmatter runs once, at build time, and the result gets baked straight into the static HTML. No client-side request, no per-visitor cost, no third party in the loop at all. That’s a better fit here than the pattern I’d used for IndexNow submission (an astro:build:done integration), because that hook runs after the page has already rendered. It can push data out, but it can’t get data into the page.
The gotcha: a clean build that shipped nothing
First pass, the build succeeded, no errors, no warnings. I checked the built HTML for the old third-party domain: gone, as expected. Called it done in my head for about thirty seconds, then actually looked for the stat line itself. It wasn’t there. Not on /, not on /es/.
curl https://api.github.com/users/vdaluz from my own shell returned a clean 200 with real JSON. So the API was fine, the network was fine, and yet the exact same request from inside the Astro build was failing silently and getting swallowed by a try/catch that just set the result back to null.
Turned out curl sends its own default User-Agent string, and fetch() doesn’t send one at all unless you set it yourself. GitHub’s REST API returns a flat 403 to any request with no User-Agent header. My try/catch was working exactly as designed, catching a failure and degrading gracefully, which is also exactly how it hid the bug. response.ok was just false, no exception, no console noise, nothing to trip over.
Fix was one line: set User-Agent and Accept headers explicitly on the fetch call. But the real lesson was in how I’d been checking my own work. “The old bad thing is gone” and “the new good thing is present” are two different claims, and a build that silently does neither passes the first check just as easily as a correct one does. I went back and specifically grepped the built HTML for the actual numbers (“28 public repos”) before trusting it, and that’s what caught it.
What shipped
Deleted the hotlinked images and the now-unused pinnedRepos data. A native, same-origin stat line built from a single unauthenticated REST call, degrading to nothing (not zeros, not stale data) if the API call fails for any reason. The old stats domain dropped from the CSP img-src allowance in both the static headers file and the SSR middleware, since nothing references it anymore. And verification against the live site after deploy, not just localhost: curled production, confirmed the real numbers were baked in and the old domain was gone from the response entirely.
Small feature, but it’s a decent reminder that “did the build pass” and “did the feature actually work” are not the same question, especially once a try/catch is involved.
Related reading
A consent banner that was right on this site and wrong in the package
Portuguese copy sat correctly configured in this repo for months and never rendered once, because the package decides which locale keys are valid - and a stale deploy nearly convinced me the fix hadn't worked.
The filter pills existed, just not where anyone could find them
Per-project filtered views with a nice pill nav - reachable only from homepage cards. The main /blog index, where everyone actually lands, had no way in at all.
The CTA that pointed at the wrong dev log
The only call to action on the Deep Cut Atlas page linked to the whole unfiltered blog - a link that worked, returned 200, and quietly sent everyone to the wrong place for weeks.