Adding a GitHub Contribution Graph to Next.js
Software engineer and entrepreneur based in San Francisco.
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.
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.
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.
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.
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.