GitHub
Open Github

UseGithubContributions

Fetch and display GitHub contribution data with customizable themes and real-time updates.

Installation

pnpm add @hookraft/use-github-contributions

How 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).

  1. Go to github.com/settings/tokens
  2. Generate a new token — classic is easiest; enable the read:user scope
  3. Copy the token value
  4. Add it to your project's environment variables:
# .env.local
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx

Never 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

PropTypeDefault
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

PropTypeDefault
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

PropTypeDefault
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
00
> 0 and ≤ 0.251
> 0.25 and ≤ 0.52
> 0.5 and ≤ 0.753
> 0.754

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.