Get a GitHub repo's star count without hitting rate limits
Quick answer
The star count of any public repository is one field in one endpoint, and you do not need a token to read it:
stargazers_count is the number. The endpoint returns the whole repository object, so jq is only there to pull one field out of it.
That is the whole answer if you are scripting. The rest of this post is the part that bites when you put it on a website: 60 requests per hour unauthenticated, 5,000 with a token, and a counter that calls the API on every page view will burn through either.
The field, and the ones people confuse it with
The repository object carries several counts that look interchangeable and are not:
| Field | What it counts |
|---|---|
stargazers_count | Stars. This is the number shown on the repo page |
watchers_count | Historically a duplicate of the star count in this endpoint, not "watching" |
subscribers_count | People actually watching for notifications |
forks_count | Forks |
If you have ever wondered why watchers_count matches your star count exactly, that is why — the field name outlived the change in what starring means. Use stargazers_count and be explicit about it.
The rate limits, which is what actually decides your design
GitHub's REST API allows 60 requests per hour unauthenticated and 5,000 per hour with a personal access token. Two details matter more than the numbers:
The unauthenticated limit is per IP address. On a laptop that is your quota. On a cloud host, a CI runner or anything behind a shared NAT, you are sharing those 60 requests an hour with every other tenant on the address — which is why the same script that worked locally starts returning 403 the moment it runs in CI.
Reading a public repo still counts. There is no free tier for "just a number". Every call to /repos/{owner}/{repo} spends one request whether you read one field or all of them.
You can check where you stand without spending a request against the limit:
Put those two facts together and the design follows: a public site cannot call the GitHub API per visitor. It needs its own endpoint, a token, and a cache.
1 — Your own endpoint, with the token on the server
The token belongs in an environment variable read on the server. A token shipped to the browser is a token published:
Two things in there are corrections to what this post used to publish, and both are worth stating plainly.
catch (err: unknown), not catch (err: Error). The earlier version of this post annotated the catch variable as Error, which is not legal TypeScript — the compiler rejects it with TS1196, "Catch clause variable type annotation must be any or unknown if specified". It is a hard error, not a strictness setting: anything can be thrown, so the compiler refuses to let you claim otherwise. Narrow inside the block if you need the message.
Do not forward the upstream error message to the client. The old version returned message: err.message straight from Octokit. That leaks: under rate limiting GitHub answers API rate limit exceeded for user ID 12345, which discloses the account id behind the token, and on a revoked token it answers Bad credentials, which turns your public endpoint into a live oracle for whether the PAT is still working. Log the detail server-side, return a flat message.
2 — The cache is the whole point
The Cache-Control header above is what makes the counter free:
s-maxage=3600 tells the CDN to keep the response for an hour, so a thousand visitors in that hour produce one call to GitHub — 1 of your 5,000, not 1,000. stale-while-revalidate=86400 is the part people skip: for a day after the response goes stale, the edge keeps serving the last known number while it refreshes in the background. If GitHub is down or you are rate-limited, the widget shows a slightly old count instead of an error.
That trade is easy here because star counts are not urgent. Nobody notices a number that is fifty minutes old; everybody notices a broken widget.
3 — The component
Fetching in a useEffect and encoding states as magic numbers is what the original version of this post did, and it aged badly. 0 meant loading, -1 meant error, and there was a branch for -2 that nothing ever assigned — dead code rendering a state that could not happen.
A data-fetching library gives you the three states as actual states:
dedupingInterval matters more than it looks: without it, several components asking for the same key mount and each fires its own request. With it, they share one.
The component then renders loading, error and success without inventing sentinel values:
The aria-label is not decoration. Without it this link's entire content is a star icon and a bare number — and while the count is loading, a spinner and nothing else. An accessibility check on this site flagged it as a link with no accessible name, announced to a screen reader as "link" with no destination.
If you only need the number once
All of the above is for a live counter. If the count only has to be right at build time, skip the endpoint entirely and read it in getStaticProps: one request per build, no runtime quota, no cache to reason about. The reason this site does not is that the value has to update between deploys and the blog is statically generated — the endpoint exists to give a static page one live number.
The mistakes worth remembering
catch (err: Error)does not compile. Useunknown.- Never return the upstream error message from a public endpoint.
- 60 requests an hour is per IP, and shared IPs share it.
- Cache at the edge, not in a module variable — serverless functions do not keep it.
stale-while-revalidateis what turns an outage into a slightly stale number.
Right now you only have to look up to see the result, the counter is highlighted with a star.
Please if you think this content has helped you or you like it, please give me your star. 🤩
