// TL;DR
My portfolio renders live GitHub contributions, repo counts, stars and a byte-accurate language breakdown without a personal access token. Three things make that survivable: the contribution calendar comes from a public third-party endpoint (GitHub's own calendar is GraphQL-only, and GraphQL always needs auth), every fetch is cached for 86400 seconds by Next's ISR, and every fetcher returns null instead of throwing — so a rate-limited request degrades the section rather than breaking the build.
On 8 June 2026 I rebuilt this site and added a GitHub section to the home page: a 365-day contribution heatmap, streaks, public repos, stars, followers. Everything you would normally get by pasting a third-party widget into a README. I wanted it rendered by my own components, from my own data fetch, on a page I control.
The obvious way to do that is a personal access token in an environment variable. I did not want one. A PAT on a public marketing site is a credential that has to be provisioned, rotated, kept out of the image, and remembered a year later when it silently expires and the page goes blank. So the constraint I set was: the site must work with no token at all, and a token — if one happens to exist — may only make it better.
//Why the calendar comes from someone else's API
The first wall is that GitHub's REST API does not expose the contribution calendar. That green grid lives only in the GraphQL API, and GraphQL requires authentication for every query, including public data. Token-free and contribution heatmap are, on GitHub's own surface, mutually exclusive.
So the heatmap is the one thing I do not fetch from GitHub. It comes from a public third-party mirror of the calendar, which takes a username and a year and answers without credentials.
const res = await fetch(`https://github-contributions-api.jogruber.de/v4/${USERNAME}?y=${year}`, {
next: { revalidate: REVALIDATE }
})
if (!res.ok) return null
const json = (await res.json()) as { total?: Record<string, number>; contributions?: ContributionDay[] }
const total = year === 'last' ? (json.total?.lastYear ?? 0) : (json.total?.[year] ?? 0)
return { total, days: json.contributions ?? [] }year is 'last' (trailing 12 months) or a 4-digit year.This is the weakest link in the whole design and I should say so plainly. I have traded a credential I control for an availability dependency I do not. If that endpoint disappears, my heatmap disappears with it, and no amount of caching saves me past a day. The counterweight is that the alternative was a token, and everything else on the page — repos, stars, followers, languages — still comes straight from GitHub's public REST API.
//The token is optional, and only ever optional
function ghHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'User-Agent': `${USERNAME}-portfolio`,
'Accept': 'application/vnd.github+json'
}
if (process.env.GITHUB_TOKEN) headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`
return headers
}That if is the entire authentication story. There is no branch anywhere else in the module, no separate authenticated code path, no error when the variable is missing. In the deployed container GITHUB_TOKEN is simply not set. It is set in one place — the GitHub Actions workflow that regenerates my self-hosted SVG README cards, which imports the same module and gets the higher rate limit for free because Actions hands it a token anyway.
Keeping the token optional rather than required is what let one module serve two very different callers: a public container with no secrets, and a CI job that has one lying around.
//Caching is the rate-limit strategy
Every fetch in the module passes next: { revalidate: REVALIDATE }, and REVALIDATE is 86400. Contribution counts change once a day at most in any way a visitor would notice, so a day-old number is not stale, it is correct. Traffic to the home page therefore does not translate into traffic to GitHub: the page is prerendered and re-rendered on a daily cadence, and the fetch cache absorbs the rest. The dedicated /github page awaits searchParams for its year selector, so it renders per request — but it reads the same cached fetches, so a visitor clicking through 2020, 2021 and 2022 costs one upstream call per year, per day.
//The fan-out I am least comfortable with
The language breakdown is the expensive part. A repo's language field is only its single dominant language, so a site built mostly of CSS and HTML shows up as nothing but TypeScript. To get honest bytes I have to call /repos/:full_name/languages for every repo and sum the maps.
await Promise.all(
repos.map(async r => {
try {
const res = await fetch(`https://api.github.com/repos/${r.full_name}/languages`, {
headers: ghHeaders(),
next: { revalidate: REVALIDATE }
})
if (!res.ok) return
const data = (await res.json()) as Record<string, number>
for (const [name, bytes] of Object.entries(data)) totals.set(name, (totals.get(name) ?? 0) + bytes)
} catch {
// skip this repo's languages on failure
}
})
)The honest version of this design is that I am within budget because my repo count is small and my traffic is not. If either changed I would need a real answer — a prebuilt snapshot committed at build time, or a single cached aggregate — rather than a fan-out that happens to fit.
//Every failure returns null
All three exported fetchers — getContributions, getProfile, getRepos — wrap their body in try/catch, return null on a non-OK response, and never throw. That choice is what makes a token-free design deployable at all: Next.js prerenders these pages at build time, so an upstream hiccup during a Railway deploy would otherwise fail the build over a decorative heatmap.
The component then degrades in two tiers. If the contributions call returns null the whole section is omitted — no empty grid, no skeleton that never fills. If only the profile or repos call fails, the heatmap still renders and the affected counters print an em dash instead of a number.
The cost is that this is a failure mode with no alarm. A section can quietly vanish from my home page, or four stat cards can read as dashes, and the site is still green everywhere I would look: the build passed, the container is healthy, nothing logged. The number that says how many people I persuaded before I noticed is unknowable. I accepted that because the data is decoration and the alternative was a build that breaks on someone else's uptime — but degrade-silently is a decision, not a free lunch, and I would not make the same call for anything a visitor came here to read.
// What I'd Do Differently
- Making the credential optional rather than required is what let one module serve both a secretless container and a CI job. If I had written the token as a requirement with a fallback, I would have ended up with two code paths and only tested one.
- Caching was the rate-limit answer, not backoff or retries. Setting
revalidateto a full day was the cheapest correct decision in the module, because the underlying numbers genuinely do not change faster than that. - Silent degradation needs a matching habit: if nothing errors when a section disappears, then looking at the page is the only monitoring I have. I would add a build-time warning next time, so at least the deploy log says the data was missing.
FAQ
5Q1 //Can you fetch a GitHub contribution graph without a token?
Not from GitHub directly. The contribution calendar is exposed only through GitHub's GraphQL API, and GraphQL requires authentication for every query, even for public data. The REST API has no equivalent endpoint. The token-free options are a third-party mirror of the calendar, or scraping the profile page HTML.
Q2 //What are the GitHub REST API rate limits without authentication?
Unauthenticated REST requests are limited per IP address, at 60 requests per hour — versus 5,000 per hour once you send a token. That is fine for a handful of endpoints behind a cache, and not fine for a per-repo fan-out on every page view, which is why the caching layer matters more than the request code.
Q3 //How do I cache external API calls in the Next.js App Router?
Pass next: { revalidate: <seconds> } to fetch in a server component. Next caches the response and serves it to every render until the window expires, then refreshes in the background. Because it is per-fetch rather than per-page, several pages can share one cached upstream call — my home page and my dedicated GitHub page read exactly the same cached data.
Q4 //Why sum every repository language instead of using the repo language field?
A repository's language field is only its single dominant language, so a portfolio full of styling and markup reports as pure TypeScript. Calling /repos/:owner/:repo/languages returns a byte count per language, and summing those maps across all repos gives a breakdown that reflects what is actually in the code. It costs one extra request per repository.
Q5 //Should a data fetch failure break a Next.js build?
It depends on whether the data is the point of the page. For decorative or supplementary data, returning null and omitting the section keeps deploys independent of someone else’s uptime. For content a visitor came to read, failing loudly is better — a silently missing section looks identical to a healthy site from every angle you would normally check.