Installation
pnpm add @hookraft/use-github-contributionsHow it works
useGithubContributions fetches GitHub contribution data through a server-side proxy route you provide. The proxy calls the GitHub GraphQL API (/graphql) with a personal access token, then returns a structured JSON response — no HTML scraping, no CORS issues, no fragile DOM parsing.
The hook receives a clean JSON payload from your proxy, aligns days into Sunday-anchored weeks (matching GitHub's grid layout), and computes streak stats — giving you the same 53-column heatmap GitHub renders on profile pages.
Prerequisites: GitHub Personal Access Token
The GraphQL API requires authentication. You need a classic or fine-grained personal access token with at minimum the read:user scope (for public contribution data).
- Go to github.com/settings/tokens
- Generate a new token — classic is easiest; enable the
read:userscope - Copy the token value
- Add it to your project's environment variables:
# .env.local
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxNever expose this token client-side. It lives only on your server inside the proxy route.
Setting up the proxy
The hook calls your own /api/github-contributions endpoint (or any URL you pass as proxyUrl). The proxy authenticates with GitHub on your behalf and returns JSON.
Next.js App Router
// app/api/github-contributions/route.ts
import { NextRequest, NextResponse } from "next/server"
export const runtime = "edge"
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const username = searchParams.get("username")?.trim()
const year = parseInt(searchParams.get("year") || "", 10)
if (!username) {
return NextResponse.json({ error: "Missing username" }, { status: 400 })
}
if (!/^[a-zA-Z0-9-]{1,39}$/.test(username)) {
return NextResponse.json({ error: "Invalid GitHub username" }, { status: 400 })
}
const now = new Date()
const targetYear = year || now.getFullYear()
const from = new Date(`${targetYear}-01-01T00:00:00Z`).toISOString()
const to = new Date(`${targetYear}-12-31T23:59:59Z`).toISOString()
const query = `
query($username: String!, $from: DateTime!, $to: DateTime!) {
user(login: $username) {
contributionsCollection(from: $from, to: $to) {
contributionCalendar {
weeks {
contributionDays {
date
contributionCount
}
}
}
}
}
}
`
try {
const ghRes = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables: { username, from, to } }),
next: { revalidate: 3600 }, // cache 1 hour
})
const json = await ghRes.json()
if (!ghRes.ok || json.errors) {
return NextResponse.json(
{ error: json.errors?.[0]?.message ?? "GitHub API error" },
{ status: 500 }
)
}
if (!json.data?.user) {
return NextResponse.json(
{ error: `User "${username}" not found or contributions are private.` },
{ status: 404 }
)
}
const weeks = json.data.user.contributionsCollection.contributionCalendar.weeks
// Flatten days for max-based level scaling
const allDays = weeks.flatMap((w: any) => w.contributionDays)
const max = Math.max(...allDays.map((d: any) => d.contributionCount), 0)
const mappedWeeks = weeks.map((w: any) => ({
days: w.contributionDays.map((d: any) => {
const count = d.contributionCount
let level: 0 | 1 | 2 | 3 | 4 = 0
if (count > 0) {
const ratio = count / (max || 1)
if (ratio > 0.75) level = 4
else if (ratio > 0.5) level = 3
else if (ratio > 0.25) level = 2
else level = 1
}
return { date: d.date, count, level }
}),
}))
const totalContributions = allDays.reduce(
(sum: number, d: any) => sum + d.contributionCount, 0
)
return NextResponse.json(
{ weeks: mappedWeeks, totalContributions },
{ headers: { "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400" } }
)
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 })
}
}Next.js Pages Router
// pages/api/github-contributions.ts
import type { NextApiRequest, NextApiResponse } from "next"
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const username = (req.query.username as string)?.trim()
const year = parseInt(req.query.year as string || "", 10)
if (!username) return res.status(400).json({ error: "Missing username" })
if (!/^[a-zA-Z0-9-]{1,39}$/.test(username)) {
return res.status(400).json({ error: "Invalid GitHub username" })
}
const now = new Date()
const targetYear = year || now.getFullYear()
const from = new Date(`${targetYear}-01-01T00:00:00Z`).toISOString()
const to = new Date(`${targetYear}-12-31T23:59:59Z`).toISOString()
const query = `
query($username: String!, $from: DateTime!, $to: DateTime!) {
user(login: $username) {
contributionsCollection(from: $from, to: $to) {
contributionCalendar {
weeks {
contributionDays {
date
contributionCount
}
}
}
}
}
}
`
try {
const ghRes = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables: { username, from, to } }),
})
const json = await ghRes.json()
if (!ghRes.ok || json.errors) {
return res.status(500).json({ error: json.errors?.[0]?.message ?? "GitHub API error" })
}
if (!json.data?.user) {
return res.status(404).json({ error: `User "${username}" not found.` })
}
const weeks = json.data.user.contributionsCollection.contributionCalendar.weeks
const allDays = weeks.flatMap((w: any) => w.contributionDays)
const max = Math.max(...allDays.map((d: any) => d.contributionCount), 0)
const mappedWeeks = weeks.map((w: any) => ({
days: w.contributionDays.map((d: any) => {
const count = d.contributionCount
let level: 0 | 1 | 2 | 3 | 4 = 0
if (count > 0) {
const ratio = count / (max || 1)
if (ratio > 0.75) level = 4
else if (ratio > 0.5) level = 3
else if (ratio > 0.25) level = 2
else level = 1
}
return { date: d.date, count, level }
}),
}))
const totalContributions = allDays.reduce(
(sum: number, d: any) => sum + d.contributionCount, 0
)
res.setHeader("Cache-Control", "public, s-maxage=3600, stale-while-revalidate=86400")
res.status(200).json({ weeks: mappedWeeks, totalContributions })
} catch (err) {
res.status(500).json({ error: String(err) })
}
}useGithubContributions
import { useGithubContributions } from "@hookraft/use-github-contributions"const { data, loading, error, refetch } = useGithubContributions({
username: "torvalds",
year: 2024,
onLoad: (data) => console.log(`${data.totalContributions} contributions`),
})Options
| Prop | Type | Default |
|---|---|---|
username | string | - |
year | number | - |
proxyUrl | string | "/api/github-contributions" |
theme | "github" | "halloween" | "winter" | "pink" | "dracula" | "custom" | "github" |
customColors | CustomColors | - |
onLoad | (data: ContributionData) => void | - |
onError | (error: Error) => void | - |
Return value
| Prop | Type | Default |
|---|---|---|
data | ContributionData | null | - |
loading | boolean | - |
error | Error | null | - |
refetch | () => void | - |
Types
interface ContributionDay {
date: string // "YYYY-MM-DD"
count: number // raw contribution count
level: 0 | 1 | 2 | 3 | 4 // intensity bucket (0 = none, 4 = highest)
}
interface ContributionWeek {
days: ContributionDay[] // always 7 entries, Sun → Sat
}
interface ContributionData {
weeks: ContributionWeek[]
totalContributions: number
currentStreak: number
longestStreak: number
}
interface CustomColors {
empty: string // hex — no-contribution cells
level1: string // hex — lowest activity
level2: string
level3: string
level4: string // hex — highest activity
}
type Theme = "github" | "halloween" | "winter" | "pink" | "dracula" | "custom"ContributionCalendar
Drop-in component that wraps useGithubContributions and renders the full heatmap — month labels, day labels, block grid, Less/More legend, total count, and a hover tooltip. Includes a built-in theme switcher and scrolls horizontally on narrow viewports.
import { ContributionCalendar } from "@hookraft/use-github-contributions"<ContributionCalendar
username="torvalds"
theme="halloween"
blockSize={13}
onContributionClick={(day) => console.log(day)}
/>Props
| Prop | Type | Default |
|---|---|---|
username | string | - |
year | number | - |
proxyUrl | string | "/api/github-contributions" |
theme | "github" | "halloween" | "winter" | "pink" | "dracula" | "custom" | "github" |
customColors | CustomColors | - |
blockSize | number | 11 |
blockGap | number | 3 |
showMonthLabels | boolean | true |
showDayLabels | boolean | true |
showThemeSwitcher | boolean | true |
onContributionClick | (day: ContributionDay) => void | - |
className | string | - |
style | CSSProperties | - |
Level scaling
Since the GraphQL API returns raw contributionCount values without GitHub's internal bucket thresholds, the proxy computes levels dynamically based on the maximum count in the queried period:
Ratio (count / max) | Level |
|---|---|
0 | 0 |
> 0 and ≤ 0.25 | 1 |
> 0.25 and ≤ 0.5 | 2 |
> 0.5 and ≤ 0.75 | 3 |
> 0.75 | 4 |
This means the darkest cell always represents the user's personal peak — the same visual behaviour GitHub uses.
Examples
Themes
Switch between the five built-in palettes. Theme changes apply immediately — no refetch, only the color map updates.