A rate-limit protected component to display GitHub star counts with multi-level server proxying, 24-hour local caching, and instant fallback rendering.
Test custom repositories and observe initial star behavior.
Prevents initial skeleton flicker during SSR or initial load.
Enter a repository path to preview
e.g., facebook/react or vercel/next.js
Cascades between Proxy, GitHub API, and Shields.io so it never breaks under load.
Caches star counts in localStorage for 24h instant loads.
Supports GITHUB_TOKEN to unlock 5,000 req/hr capacity.
Built on Radix primitives, Lucide icons, and Tailwind utility classes.
Install Lucide icons and required Radix tooltip component.
pnpm add lucide-react @radix-ui/react-tooltipCreate app/api/github-stars/route.ts to handle caching and rate limiting.
import { NextResponse } from "next/server"
// In-memory cache for serverless instance lifetime
const memoryCache = new Map<string, { stars: number; timestamp: number }>()
const CACHE_TTL = 3600 * 1000 // 1 hour
function parseShieldsStars(message: string): number | null {
if (!message) return null
const cleaned = message.trim().toLowerCase()
if (cleaned.endsWith("k")) {
const num = parseFloat(cleaned.slice(0, -1))
return isNaN(num) ? null : Math.round(num * 1000)
}
if (cleaned.endsWith("m")) {
const num = parseFloat(cleaned.slice(0, -1))
return isNaN(num) ? null : Math.round(num * 1000000)
}
const parsed = parseInt(cleaned, 10)
return isNaN(parsed) ? null : parsed
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const repo = searchParams.get("repo")
if (!repo || !repo.includes("/")) {
return NextResponse.json(
{ error: "Invalid repository format" },
{ status: 400 }
)
}
const cleanRepo = repo.trim().replace(/\s+/g, "")
const cached = memoryCache.get(cleanRepo)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return NextResponse.json(
{ stars: cached.stars, cached: true },
{
headers: {
"Cache-Control":
"public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400",
},
}
)
}
let stars: number | null = null
// 1. Try official GitHub API (uses GITHUB_TOKEN if configured)
try {
const headers: Record<string, string> = {
"User-Agent": "Rimu-GitHub-Stars-App",
Accept: "application/vnd.github.v3+json",
}
const token =
process.env.GITHUB_TOKEN || process.env.NEXT_PUBLIC_GITHUB_TOKEN
if (token) {
headers.Authorization = `Bearer ${token}`
}
const res = await fetch(`https://api.github.com/repos/${cleanRepo}`, {
headers,
next: { revalidate: 3600 },
})
if (res.ok) {
const data = await res.json()
if (typeof data.stargazers_count === "number") {
stars = data.stargazers_count
}
}
} catch {}
// 2. Fallback to Shields.io API if rate limited or blocked
if (stars === null) {
try {
const shieldsRes = await fetch(
`https://img.shields.io/github/stars/${cleanRepo}.json`,
{ next: { revalidate: 3600 } }
)
if (shieldsRes.ok) {
const shieldsData = await shieldsRes.json()
stars = parseShieldsStars(shieldsData.message)
}
} catch {}
}
// 3. Fallback to stale memory cache if external endpoints failed
if (stars === null && cached) {
stars = cached.stars
}
if (stars !== null) {
memoryCache.set(cleanRepo, { stars, timestamp: Date.now() })
return NextResponse.json(
{ stars, cached: false },
{
headers: {
"Cache-Control":
"public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400",
},
}
)
}
return NextResponse.json(
{ stars: cached?.stars ?? 0, error: "Rate limit exceeded or repo unavailable" },
{ status: 200 }
)
} catch (error) {
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 }
)
}
}Create components/ui/github-stars.tsx.
"use client"
import { buttonVariants } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { useEffect, useState } from "react"
import { Github } from "lucide-react"
type GitHubStarsProps = {
repo: string
initialStars?: number
className?: string
}
function formatFullNumber(num: number) {
return new Intl.NumberFormat("en-US").format(num)
}
function parseShieldsStars(message: string): number | null {
if (!message) return null
const cleaned = message.trim().toLowerCase()
if (cleaned.endsWith("k")) {
const num = parseFloat(cleaned.slice(0, -1))
return isNaN(num) ? null : Math.round(num * 1000)
}
if (cleaned.endsWith("m")) {
const num = parseFloat(cleaned.slice(0, -1))
return isNaN(num) ? null : Math.round(num * 1000000)
}
const parsed = parseInt(cleaned, 10)
return isNaN(parsed) ? null : parsed
}
export function GitHubStars({
repo,
initialStars = 0,
className,
}: GitHubStarsProps) {
const cleanRepo = repo ? repo.trim().replace(/s+/g, "") : ""
const cacheKey = cleanRepo ? `gh_stars_v2_${cleanRepo}` : ""
const [stars, setStars] = useState<number>(() => {
if (typeof window === "undefined" || !cacheKey) return initialStars
try {
const cachedRaw = localStorage.getItem(cacheKey)
if (cachedRaw) {
const { stars: storedStars } = JSON.parse(cachedRaw)
if (typeof storedStars === "number") return storedStars
}
} catch {}
return initialStars
})
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState<boolean>(() => {
if (initialStars > 0 || stars > 0) return false
return true
})
useEffect(() => {
let isMounted = true
const controller = new AbortController()
if (!cleanRepo || !cleanRepo.includes("/")) return
// If cache is fresh (< 1 hour old), skip network request completely
try {
const cachedRaw = localStorage.getItem(cacheKey)
if (cachedRaw) {
const { timestamp } = JSON.parse(cachedRaw)
if (timestamp && Date.now() - timestamp < 60 * 60 * 1000) {
return
}
}
} catch {}
const fetchStars = async () => {
try {
let fetchedStars: number | null = null
// Priority 1: Call internal proxy route
try {
const internalRes = await fetch(
`/api/github-stars?repo=${encodeURIComponent(cleanRepo)}`,
{ signal: controller.signal }
)
if (internalRes.ok) {
const data = await internalRes.json()
if (typeof data.stars === "number" && data.stars >= 0) {
fetchedStars = data.stars
}
}
} catch {}
// Priority 2: Direct GitHub API fallback
if (fetchedStars === null) {
try {
const ghRes = await fetch(
`https://api.github.com/repos/${cleanRepo}`,
{ signal: controller.signal }
)
if (ghRes.ok) {
const ghData = await ghRes.json()
if (typeof ghData.stargazers_count === "number") {
fetchedStars = ghData.stargazers_count
}
}
} catch {}
}
// Priority 3: Shields.io API fallback
if (fetchedStars === null) {
try {
const shieldsRes = await fetch(
`https://img.shields.io/github/stars/${cleanRepo}.json`,
{ signal: controller.signal }
)
if (shieldsRes.ok) {
const shieldsData = await shieldsRes.json()
fetchedStars = parseShieldsStars(shieldsData.message)
}
} catch {}
}
if (!isMounted) return
if (fetchedStars !== null) {
setStars(fetchedStars)
setError(null)
try {
localStorage.setItem(
cacheKey,
JSON.stringify({ stars: fetchedStars, timestamp: Date.now() })
)
} catch {}
} else if (stars === 0 && initialStars === 0) {
setError("Unable to load star count")
}
} catch (err: any) {
if (!isMounted || err.name === "AbortError") return
} finally {
if (isMounted) setLoading(false)
}
}
fetchStars()
return () => {
isMounted = false
controller.abort()
}
}, [cleanRepo, cacheKey, initialStars, stars])
const formattedCompact = new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 1,
})
.format(stars)
.toLowerCase()
const cleanRepoDisplay = repo.replace(/\s+/g, "")
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<a
href={`https://github.com/${cleanRepoDisplay}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Star ${cleanRepoDisplay} on GitHub`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"flex w-fit items-center gap-2 px-3 text-muted-foreground transition-colors hover:text-primary",
className
)}
>
<Github className="h-4 w-4 shrink-0" />
<span
className="mx-1 h-4 w-px shrink-0 bg-border"
aria-hidden="true"
/>
<span className="min-w-[2ch] text-[13px] tabular-nums">
{loading && stars === 0 ? "—" : formattedCompact}
</span>
</a>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="font-sans">
<div className="flex flex-col gap-1 text-center">
<span>{formatFullNumber(stars)} stars on GitHub</span>
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
Import and pass the repository prop.
import { GitHubStars } from "@/components/ui/github-stars"
export default function Page() {
return <GitHubStars repo="vercel/next.js" />
}"use client"
import { buttonVariants } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { useEffect, useState } from "react"
import { Github } from "lucide-react"
type GitHubStarsProps = {
repo: string
initialStars?: number
className?: string
}
function formatFullNumber(num: number) {
return new Intl.NumberFormat("en-US").format(num)
}
function parseShieldsStars(message: string): number | null {
if (!message) return null
const cleaned = message.trim().toLowerCase()
if (cleaned.endsWith("k")) {
const num = parseFloat(cleaned.slice(0, -1))
return isNaN(num) ? null : Math.round(num * 1000)
}
if (cleaned.endsWith("m")) {
const num = parseFloat(cleaned.slice(0, -1))
return isNaN(num) ? null : Math.round(num * 1000000)
}
const parsed = parseInt(cleaned, 10)
return isNaN(parsed) ? null : parsed
}
export function GitHubStars({
repo,
initialStars = 0,
className,
}: GitHubStarsProps) {
const cleanRepo = repo ? repo.trim().replace(/s+/g, "") : ""
const cacheKey = cleanRepo ? `gh_stars_v2_${cleanRepo}` : ""
const [stars, setStars] = useState<number>(() => {
if (typeof window === "undefined" || !cacheKey) return initialStars
try {
const cachedRaw = localStorage.getItem(cacheKey)
if (cachedRaw) {
const { stars: storedStars } = JSON.parse(cachedRaw)
if (typeof storedStars === "number") return storedStars
}
} catch {}
return initialStars
})
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState<boolean>(() => {
if (initialStars > 0 || stars > 0) return false
return true
})
useEffect(() => {
let isMounted = true
const controller = new AbortController()
if (!cleanRepo || !cleanRepo.includes("/")) return
// If cache is fresh (< 1 hour old), skip network request completely
try {
const cachedRaw = localStorage.getItem(cacheKey)
if (cachedRaw) {
const { timestamp } = JSON.parse(cachedRaw)
if (timestamp && Date.now() - timestamp < 60 * 60 * 1000) {
return
}
}
} catch {}
const fetchStars = async () => {
try {
let fetchedStars: number | null = null
// Priority 1: Call internal proxy route
try {
const internalRes = await fetch(
`/api/github-stars?repo=${encodeURIComponent(cleanRepo)}`,
{ signal: controller.signal }
)
if (internalRes.ok) {
const data = await internalRes.json()
if (typeof data.stars === "number" && data.stars >= 0) {
fetchedStars = data.stars
}
}
} catch {}
// Priority 2: Direct GitHub API fallback
if (fetchedStars === null) {
try {
const ghRes = await fetch(
`https://api.github.com/repos/${cleanRepo}`,
{ signal: controller.signal }
)
if (ghRes.ok) {
const ghData = await ghRes.json()
if (typeof ghData.stargazers_count === "number") {
fetchedStars = ghData.stargazers_count
}
}
} catch {}
}
// Priority 3: Shields.io API fallback
if (fetchedStars === null) {
try {
const shieldsRes = await fetch(
`https://img.shields.io/github/stars/${cleanRepo}.json`,
{ signal: controller.signal }
)
if (shieldsRes.ok) {
const shieldsData = await shieldsRes.json()
fetchedStars = parseShieldsStars(shieldsData.message)
}
} catch {}
}
if (!isMounted) return
if (fetchedStars !== null) {
setStars(fetchedStars)
setError(null)
try {
localStorage.setItem(
cacheKey,
JSON.stringify({ stars: fetchedStars, timestamp: Date.now() })
)
} catch {}
} else if (stars === 0 && initialStars === 0) {
setError("Unable to load star count")
}
} catch (err: any) {
if (!isMounted || err.name === "AbortError") return
} finally {
if (isMounted) setLoading(false)
}
}
fetchStars()
return () => {
isMounted = false
controller.abort()
}
}, [cleanRepo, cacheKey, initialStars, stars])
const formattedCompact = new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 1,
})
.format(stars)
.toLowerCase()
const cleanRepoDisplay = repo.replace(/\s+/g, "")
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<a
href={`https://github.com/${cleanRepoDisplay}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Star ${cleanRepoDisplay} on GitHub`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"flex w-fit items-center gap-2 px-3 text-muted-foreground transition-colors hover:text-primary",
className
)}
>
<Github className="h-4 w-4 shrink-0" />
<span
className="mx-1 h-4 w-px shrink-0 bg-border"
aria-hidden="true"
/>
<span className="min-w-[2ch] text-[13px] tabular-nums">
{loading && stars === 0 ? "—" : formattedCompact}
</span>
</a>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="font-sans">
<div className="flex flex-col gap-1 text-center">
<span>{formatFullNumber(stars)} stars on GitHub</span>
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
| Prop | Type | Default | Description |
|---|---|---|---|
| repo | string | Required | The GitHub repository path in owner/repo format (e.g., vercel/next.js). |
| initialStars | number | 0 | Optional initial star count for immediate rendering during SSR or initial page load. |
| className | string | undefined | Additional Tailwind CSS utility classes to customize styling. |
<GitHubStars repo="facebook/react" />
<GitHubStars repo="vercel/next.js" initialStars={141000} /><GitHubStars repo="tailwindlabs/tailwindcss" className="border-primary/50 text-primary" />