A Live GitHub Contribution Heatmap in Next.js, Without a Token in the Browser
How this site's homepage renders real GitHub contribution data: a server-fetched calendar cached for an hour, a documented API with an undocumented fallback, and one small client island for the tooltip.
What it had to be, and what it must not become
The brief for the homepage was one specific thing: a real GitHub contribution heatmap, the 53 by 7 grid, updated from GitHub rather than maintained by hand, with a hover showing the real date and count. Not a dashboard. No repository list, no follower count, no language chart. One calendar, integrated into the page rather than embedded as someone else's widget.
Two constraints followed from that. The data has to come from the server, so no token is ever shipped to a browser. And the homepage must not make a GitHub request per visitor, because a personal site does not need a rate-limit problem.
Two sources, chosen by an environment variable
The documented path is GitHub's GraphQL API. The contributionsCollection field carries a contributionCalendar with a total and a list of weeks, and each day has a date, a count, a weekday, GitHub's own colour and a level from NONE to FOURTH_QUARTILE. It needs an authenticated request, but a fine-grained personal access token with no repository permissions at all is enough. Querying viewer, the token's own account, is what lets private activity count toward the total when the profile setting to show it is on; the calendar is still only dates and counts, so nothing about a private repository can come back with it.
query ContributionCalendar {
viewer {
login
contributionsCollection {
contributionCalendar {
totalContributions
weeks {
contributionDays { date contributionCount contributionLevel color weekday }
}
}
}
}
}An earlier version kept a second source: the HTML fragment GitHub renders for the profile calendar itself, public and tokenless but undocumented, parsed with a few regular expressions. It was removed once the token was in place. A calendar that claims to be live should have exactly one source of truth, and scraping markup that can change without notice is not a foundation for one. Now, if the token is missing or GitHub does not answer, the panel says the data is unavailable rather than showing anything else.
Every failure returns null from the data function: no token, a 401 or 403, a malformed reply, or a request that has not answered in eight seconds. The section then renders its unavailable state, which is a sentence, not a spinner and not a cached-looking grid of zeros.
Caching, and a fetch option that quietly disables it
In Next.js 16, fetch requests are not cached by default. Left alone, a server component would ask GitHub on every render. The fix is one option:
const res = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ query, variables: { login } }),
next: { revalidate: 3600, tags: ["github-contributions"] },
});With that, the homepage moves from fully static to incremental static regeneration with a one-hour window: at most one GitHub request per hour per deployment, whatever the traffic. The tag is there so a future route handler could invalidate it on demand.
This project does not enable Cache Components, so this is the previous caching model rather than use cache. Switching the whole app to Cache Components for one component is a larger change than the feature warrants, and the documentation covers both.
Rendering the grid on the server
The calendar is a server component. It fetches, builds the month labels (one label over the first week of each new month, skipping any that would sit within two columns of the previous one), and renders a CSS grid: one column per week, seven rows plus a header row, 11px cells with 3px gaps. There is no chart library and no client-side data fetching.
Colour is five levels mapped onto the site's accent with color-mix, so the calendar reads as part of the page in both themes rather than as a GitHub-green embed:
.heat { background: var(--color-bg-elevated-2); }
.heat-1 { background: color-mix(in oklab, var(--color-accent) 30%, var(--color-bg-elevated-2)); }
.heat-2 { background: color-mix(in oklab, var(--color-accent) 55%, var(--color-bg-elevated-2)); }
.heat-3 { background: color-mix(in oklab, var(--color-accent) 80%, var(--color-bg-elevated-2)); }
.heat-4 { background: var(--color-accent-strong); }Each cell carries data-date and a data-label such as "305 contributions on 22 September 2026". That label is the only thing the tooltip needs, and it is computed once on the server.
One client island, for the hover
The tooltip is the single piece that needs JavaScript. Rather than 371 cells each with a handler, one wrapper listens for pointerover and pointerout and reads the label off whichever cell the event came from. One element is positioned above the cell; nothing is rendered until something is hovered. The same wrapper scrolls the calendar to its right-hand end on mount, because on a phone the grid overflows sideways and the recent weeks are the interesting ones.
That keeps the client bundle for the feature to a few hundred bytes of behaviour. A grep of the built client chunks for api.github.com, the token name or the fragment URL returns nothing, which is the check I would suggest anyone run after wiring a server-only data source.
Accessibility without 371 tab stops
Making every cell focusable would give a keyboard user a year of tab presses. Instead the grid is one role="img" with a full-sentence aria-label: the total, and the first and last dates. The total, the date range and a legend are also real text beside the grid. Hover detail is a pointer affordance; the summary is the accessible one.
Reduced motion needs nothing special here. Cells have a small scale on hover and no animation otherwise, and the site-wide reduced-motion rule already shortens transitions to nothing.
What I would change with more time
- A revalidation route that invalidates the tag from a GitHub webhook, so a push shows up in minutes rather than within the hour. It is the reason the tag exists.
- Weekday and month labels that adapt below 400px. Today the grid scrolls; a compact half-year view would be nicer on the smallest phones.
- Private contribution counts. The API only includes them when the profile setting to show private contributions is on, and that is a GitHub setting rather than a code change.
The implementation is on the homepage. If your site is on the same stack, the whole thing is a data function, a server component and a forty-line client wrapper.
Related
Keep reading
Gating a CV Download Behind a Server-Signed Grant in Next.js
A public portfolio, a CV with a phone number in it, and a public repository. How this site releases three contact fields and one PDF only after a form, without a database and without trusting the browser.
Building Software in Cyberjaya: The Development Environment Behind the Work
What a production operations system, a client website, an AI research assistant and a vision inspector actually get built on, from a laptop in Cyberjaya: the machine, the stack, where things are hosted, and the local details that shaped the code.
Printing Labels From the Browser Over Web Bluetooth: The RPOMS Print Engine
A scan-to-label workflow that drives a NIIMBOT B1 Pro directly from Chrome, with no vendor app and no backend. What the protocol work looked like, how 116 tests run without a printer, and what the first real print taught.