Adding a GitHub Contribution Graph to Next.js

Software engineer and entrepreneur based in San Francisco.

Adding your GitHub contribution graph to a personal site is a nice touch. Since GitHub doesn't offer a direct embed, you'll need a custom solution. This guide shows how to fetch contribution data using GitHub's GraphQL API and display it in your Next.js app.
1: Fetching Contribution Data with GraphQL
The best way to get contribution data is via GitHub's GraphQL API. You'll need a personal access token with permission to read user contribution data. Store this securely, for example, as an environment variable (GITHUB_ACCESS_TOKEN_COMMIT_GRAPH
).
Here's the query to get the contribution calendar for a specific user and date range:
// lib/github.ts (example location)
import { graphql } from "@octokit/graphql"; // Assuming you use @octokit/graphql
interface GitHubGraphQLContributionResponse {
user: {
contributionsCollection: {
contributionCalendar: {
weeks: Array<{
contributionDays: Array<{
contributionCount: number;
contributionLevel: string; // e.g., NONE, FIRST_QUARTILE
date: string; // YYYY-MM-DD
}>;
}>;
totalContributions: number;
};
};
};
}
interface ContributionDay {
date: string;
count: number;
level: number; // 0-4
}
const GITHUB_USERNAME = 'your-github-username'; // Replace with your username
const GITHUB_API_TOKEN = process.env.GITHUB_ACCESS_TOKEN_COMMIT_GRAPH;
export async function fetchGitHubContributions(fromDate: string, toDate: string): Promise<{ contributions: ContributionDay[], totalContributions: number }> {
if (!GITHUB_API_TOKEN) {
throw new Error("Missing GITHUB_ACCESS_TOKEN_COMMIT_GRAPH environment variable.");
}
const { user } = await graphql<GitHubGraphQLContributionResponse>(
`
query($username: String!, $from: DateTime!, $to: DateTime!) {
user(login: $username) {
contributionsCollection(from: $from, to: $to) {
contributionCalendar {
weeks {
contributionDays {
contributionCount
contributionLevel
date
}
}
totalContributions
}
}
}
}
`,
{
username: GITHUB_USERNAME,
from: `${fromDate}T00:00:00Z`,
to: `${toDate}T23:59:59Z`,
headers: {
authorization: `bearer ${GITHUB_API_TOKEN}`,
},
}
);
if (!user?.contributionsCollection) {
throw new Error("Incomplete data received from GitHub GraphQL.");
}
const contributionDays = user.contributionsCollection.contributionCalendar.weeks.flatMap(
(week) => week.contributionDays
);
const mapContributionLevel = (level: string): number => {
switch (level) {
case 'NONE': return 0;
case 'FIRST_QUARTILE': return 1;
case 'SECOND_QUARTILE': return 2;
case 'THIRD_QUARTILE': return 3;
case 'FOURTH_QUARTILE': return 4;
default: return 0;
}
};
const contributions: ContributionDay[] = contributionDays.map((day) => ({
date: day.date,
count: day.contributionCount,
level: mapContributionLevel(day.contributionLevel),
}));
return {
contributions,
totalContributions: user.contributionsCollection.contributionCalendar.totalContributions
};
}
This function fetches the data and transforms the contributionLevel
strings (like FIRST_QUARTILE
) into simple numbers (0-4) for easier rendering.
2: Caching the Data
To avoid hitting the GitHub API on every request, cache the results server-side. An in-memory cache like node-cache
works well for this. Set a reasonable Time-To-Live (TTL), like 24 hours. You can also add a way to manually bust the cache if needed.
Here's an example API route in Next.js:
// app/api/github-activity/route.ts (example location)
import { NextResponse } from 'next/server';
import cache from '@/lib/cache'; // Your cache implementation
import { fetchGitHubContributions } from '@/lib/github'; // The function from Step 1
interface GitHubActivityApiResponse {
source: 'graphql' | 'cache';
data: ContributionDay[];
totalContributions: number;
}
const GITHUB_ACTIVITY_CACHE_KEY = 'github_activity_data';
const CACHE_TTL_DAILY = 24 * 60 * 60; // 24 hours in seconds
export async function GET(request: Request) {
const url = new URL(request.url);
const refreshCache = url.searchParams.get('refresh') === 'true';
if (refreshCache) {
cache.del(GITHUB_ACTIVITY_CACHE_KEY);
}
const cachedData = cache.get<GitHubActivityApiResponse>(GITHUB_ACTIVITY_CACHE_KEY);
if (cachedData && !refreshCache) {
// Add source indication for debugging/transparency
return NextResponse.json({ ...cachedData, source: 'cache' });
}
try {
const today = new Date();
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(today.getFullYear() - 1);
const fromDate = oneYearAgo.toISOString().split('T')[0]; // YYYY-MM-DD
const toDate = today.toISOString().split('T')[0]; // YYYY-MM-DD
const { contributions, totalContributions } = await fetchGitHubContributions(fromDate, toDate);
const activityData: GitHubActivityApiResponse = {
source: 'graphql',
data: contributions,
totalContributions: totalContributions
};
cache.set(GITHUB_ACTIVITY_CACHE_KEY, activityData, CACHE_TTL_DAILY);
return NextResponse.json(activityData);
} catch (error) {
console.error('Error fetching GitHub contributions:', error);
// Return an error response or potentially stale cache data if available
return NextResponse.json(
{ error: 'Failed to fetch GitHub activity', details: (error as Error).message },
{ status: 500 }
);
}
}
This API route first checks the cache. If data is missing or a refresh is requested, it fetches fresh data from GitHub using the function created in Step 1, caches it, and then returns it.
3: Rendering the Graph
With the data fetched (likely via a client-side fetch to your API route or passed as props from a server component), you can render the graph. A simple grid using CSS Grid and Tailwind CSS works well.
// components/ContributionGraph.tsx (example component)
import React from 'react';
interface ContributionDay {
date: string;
count: number;
level: number; // 0-4
}
const getLevelColor = (level: number): string => {
// Tailwind classes for different contribution levels
// Adjust colors to match your site's theme
switch (level) {
case 0: return 'bg-gray-100 dark:bg-gray-800'; // No contributions
case 1: return 'bg-green-200 dark:bg-green-900'; // Low
case 2: return 'bg-green-400 dark:bg-green-700'; // Medium
case 3: return 'bg-green-600 dark:bg-green-500'; // High
case 4: return 'bg-green-800 dark:bg-green-300'; // Very high
default: return 'bg-gray-100 dark:bg-gray-800';
}
};
interface ContributionGraphProps {
data: ContributionDay[];
totalContributions: number;
isLoading?: boolean;
}
export function ContributionGraph({ data, totalContributions, isLoading }: ContributionGraphProps) {
if (isLoading) {
// Optional: Add a loading skeleton state
return <div className="p-2">Loading contributions...</div>;
}
if (!data || data.length === 0) {
return <div className="p-2">Could not load contribution data.</div>;
}
return (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{totalContributions.toLocaleString()} contributions in the last year
</p>
<div className="grid grid-flow-col grid-rows-7 gap-1 p-2 border rounded-md dark:border-gray-700 overflow-x-auto bg-white dark:bg-gray-900/50">
{data.map((day) => (
<div
key={day.date}
className={`w-3 h-3 rounded-sm ${getLevelColor(day.level)}`}
title={`${day.count} contribution${day.count !== 1 ? 's' : ''} on ${day.date}`} // Tooltip
/>
))}
</div>
</div>
);
}
This component maps each ContributionDay
to a colored square, using the level
(0-4) to determine the background color via the getLevelColor
helper.
Bonus: Fixing Hydration Errors
Sometimes when making components interactive, like wrapping them in links, you might encounter React hydration errors due to invalid HTML nesting (e.g., an <a>
inside another <a>
).
Problematic (can cause errors):
<a href="URL">
<div> {isLoading ? <a href="URL">Loading...</a> : content } </div>
</a>
Solution (use a clickable div
):
const navigateToUrl = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer');
};
<div
onClick={() => navigateToUrl(URL)}
className="cursor-pointer" // Make it look clickable
role="button"
tabIndex={0} // Make it focusable
onKeyDown={(e) => e.key === 'Enter' && navigateToUrl(URL)} // Keyboard accessible
>
{isLoading ? <span>Loading...</span> : content}
</div>
Using a div
with an onClick
handler and proper accessibility attributes (role
, tabIndex
, onKeyDown
) achieves the same result without the nesting issues.