Articles /
GitHub API Rate Limit in Next.js: Go From 60 to 5,000 Requests per Hour
GitHub API rate limit in Next.js: a server-side token lifts it from 60 to 5,000 requests per hour. Parallel fetches, rate-limit headers and a clean 503.
If your Next.js app calls the GitHub REST API without a token, GitHub counts those requests against your IP address and allows 60 per hour. That GitHub API rate limit is the first wall most apps hit, and a single page load that makes several calls reaches it fast. This is how Devfolio Analyzer, a tool that scores GitHub profiles (docs), stays under the limit: a server-side token, parallel fetches, a capped fan-out, and a clean 503 when GitHub says stop.
The short answer
Send every GitHub request from your server with a token in the Authorization header. As of October 2026, GitHub documents 60 requests per hour for unauthenticated requests and 5,000 per hour for authenticated users (GitHub docs). Read x-ratelimit-remaining and x-ratelimit-reset on every response. When remaining is 0, stop and tell your caller when to come back. The code below is written for Next.js 16 (App Router).
What one analysis costs
Devfolio validates the username, then fetches the user, the repo list, public events and the contribution calendar in parallel. It ranks the repos and keeps the top 6. Only those 6 get a per-repo language breakdown.
Counting only GitHub REST calls, that is about 9 requests per analysis. This is hypothetical arithmetic from the flow, not a measurement, and pagination adds more:
- 1 user
- 1 repo list
- 1 public events
- 6 language lookups
The contribution calendar is read from the public profile page, not the REST API, so it is not one of the 9.
Without a token, 60 / 9 is 6 analyses per hour per IP. With a token, 5,000 / 9 is about 555. That gap is the whole case for authenticating.
The top-6 cap matters too. The ranking is a weighted mix of stars 0.4, forks 0.3 and recency 0.3. Without a cap, language lookups would cost one request per repo, and a profile with 80 repos would cost 80.
Send the token from the server
Create a token with the least access that works for public data. Store it as GITHUB_TOKEN in your Vercel environment variables. Never put it in client code or in a NEXT_PUBLIC_ variable.
// lib/github.ts (Next.js 16, App Router, TypeScript)
import { z } from "zod";
export const usernameSchema = z.string().regex(/^[A-Za-z0-9-]{1,39}$/);
export class GitHubRateLimitError extends Error {
constructor(public retryAfterSeconds: number) {
super("GitHub rate limit reached");
}
}
export class GitHubNotFoundError extends Error {}
async function gh<T>(path: string): Promise<T> {
const res = await fetch(`https://api.github.com${path}`, {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
},
cache: "no-store",
});
if (res.status === 403 || res.status === 429) {
const retryAfter = Number(res.headers.get("retry-after"));
if (retryAfter > 0) throw new GitHubRateLimitError(retryAfter);
if (res.headers.get("x-ratelimit-remaining") === "0") {
const resetMs = Number(res.headers.get("x-ratelimit-reset")) * 1000;
throw new GitHubRateLimitError(
Math.max(Math.ceil((resetMs - Date.now()) / 1000), 1),
);
}
}
if (res.status === 404) throw new GitHubNotFoundError();
if (!res.ok) throw new Error(`GitHub ${res.status} for ${path}`);
return res.json() as Promise<T>;
}
GitHub says that when you exceed the primary limit you get a 403 or 429 with x-ratelimit-remaining at 0. It also says not to retry before the time in x-ratelimit-reset, or before retry-after seconds if that header is present. The helper follows that order.
Fetch in parallel, cap the fan-out
The three independent calls run together. The language lookups wait for the ranking, so they run second and only for the top 6.
// same file: lib/github.ts
type Repo = {
name: string;
full_name: string;
stargazers_count: number;
forks_count: number;
pushed_at: string;
};
export async function fetchProfile(
username: string,
rank: (repos: Repo[]) => Repo[],
) {
const [user, repos, events] = await Promise.all([
gh<Record<string, unknown>>(`/users/${username}`),
gh<Repo[]>(`/users/${username}/repos?per_page=100&sort=pushed`),
gh<unknown[]>(`/users/${username}/events/public?per_page=100`),
]);
const top = rank(repos).slice(0, 6);
const settled = await Promise.allSettled(
top.map((r) => gh<Record<string, number>>(`/repos/${r.full_name}/languages`)),
);
for (const s of settled) {
if (s.status === "rejected" && s.reason instanceof GitHubRateLimitError) {
throw s.reason;
}
}
const languages = settled.map((s) => (s.status === "fulfilled" ? s.value : {}));
return { user, repos, events, top, languages };
}
Promise.allSettled means one failed language lookup does not sink the whole analysis. A rate-limit error is the exception, because every later call would fail the same way, so it is rethrown.
Return a clean error when GitHub says stop
An upstream limit is not the caller's fault. My own per-IP limiter answers with 429, so I answer GitHub's limit with 503 and a Retry-After header.
// app/api/analyze/[username]/route.ts (Next.js 16: params is a Promise)
import { NextResponse } from "next/server";
import {
fetchProfile,
GitHubNotFoundError,
GitHubRateLimitError,
usernameSchema,
} from "@/lib/github";
import { rankRepos } from "@/lib/rank"; // your ranking function
export async function GET(
_req: Request,
ctx: { params: Promise<{ username: string }> },
) {
const { username } = await ctx.params;
const parsed = usernameSchema.safeParse(username);
if (!parsed.success) {
return NextResponse.json({ error: "invalid_username" }, { status: 400 });
}
try {
return NextResponse.json(await fetchProfile(parsed.data, rankRepos));
} catch (err) {
if (err instanceof GitHubRateLimitError) {
return NextResponse.json(
{ error: "github_busy" },
{ status: 503, headers: { "Retry-After": String(err.retryAfterSeconds) } },
);
}
if (err instanceof GitHubNotFoundError) {
return NextResponse.json({ error: "user_not_found" }, { status: 404 });
}
return NextResponse.json({ error: "upstream_error" }, { status: 502 });
}
}
Devfolio also caches each result for 6 hours. Every cache hit is about 9 requests you do not spend.
Pitfalls
- One token is one shared budget. All your visitors draw from the same 5,000 per hour. Put your own limit in front of it. Devfolio allows 5 anonymous requests per hour per IP and 60 per hour with an API key. The serverless trap is covered in my rate limiting article.
- Do not build logic on an exact remaining count. Use the
x-ratelimit-*headers as the source of truth, because GitHub can change limits. - Secondary limits exist. GitHub also limits bursts, so keep the fan-out bounded and never retry in a tight loop.
- Private repos are invisible to this setup. The repo list above reads only the first page of 100, and more pages cost more requests.
- The contribution calendar is the fragile part. Reading it from the public profile HTML adds about 800 ms, and it fails silently when the user hides private contributions.
- I have not run the code above in this exact form. Test it against your own token before you ship it.
Summary
- Unauthenticated GitHub API calls get 60 per hour; with a token you get 5,000 (GitHub docs, October 2026).
- Keep the token on the server and read the
x-ratelimit-*headers on every response. - Cap the fan-out (top 6 repos) and cache results so one analysis costs about 9 requests, not hundreds.
- Return 503 with
Retry-Afterwhen GitHub says stop, and put your own rate limit in front.
Next: why in-memory counters do nothing on serverless and how to fix it: Next.js rate limiting with Upstash Redis.