diff --git a/apps/web/features/landing/components/landing-header.tsx b/apps/web/features/landing/components/landing-header.tsx
index cc0f778d7..c678f5c02 100644
--- a/apps/web/features/landing/components/landing-header.tsx
+++ b/apps/web/features/landing/components/landing-header.tsx
@@ -2,11 +2,12 @@
import { useState } from "react";
import Link from "next/link";
-import { Menu, X } from "lucide-react";
+import { Menu, Star, X } from "lucide-react";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { cn } from "@multica/ui/lib/utils";
import { useAuthStore } from "@multica/core/auth";
import { docsHrefForLocale, useLocale } from "../i18n";
+import { formatStarCount, useGithubStars } from "../utils/use-github-stars";
import { GitHubMark, githubUrl, headerButtonClassName } from "./shared";
export function LandingHeader({
@@ -16,6 +17,8 @@ export function LandingHeader({
}) {
const { t, locale } = useLocale();
const user = useAuthStore((s) => s.user);
+ const stars = useGithubStars();
+ const starsLabel = stars != null ? formatStarCount(stars) : null;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const docsHref = docsHrefForLocale(locale);
const navLinks = [
@@ -99,6 +102,7 @@ export function LandingHeader({
>
{t.header.github}
+ {starsLabel ? : null}
{t.header.github}
+ {starsLabel ? : null}
@@ -153,6 +158,20 @@ export function LandingHeader({
);
}
+/** Star-count segment appended to the header's GitHub button — a faint
+ * divider, a filled star, and the compact count (e.g. "37.6k"). Inherits the
+ * button's text color so it adapts to both the dark and light header
+ * variants. */
+function GitHubStarsBadge({ label }: { label: string }) {
+ return (
+
+
+
+ {label}
+
+ );
+}
+
function navLinkClassName(variant: "dark" | "light") {
return cn(
"inline-flex h-9 items-center rounded-[9px] px-3 text-[13px] font-medium transition-colors",
diff --git a/apps/web/features/landing/utils/use-github-stars.test.ts b/apps/web/features/landing/utils/use-github-stars.test.ts
new file mode 100644
index 000000000..8ceba6e32
--- /dev/null
+++ b/apps/web/features/landing/utils/use-github-stars.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { formatStarCount } from "./use-github-stars";
+
+describe("formatStarCount", () => {
+ it("renders counts below 1,000 exactly", () => {
+ expect(formatStarCount(0)).toBe("0");
+ expect(formatStarCount(7)).toBe("7");
+ expect(formatStarCount(999)).toBe("999");
+ });
+
+ it("formats thousands with one decimal, GitHub-style", () => {
+ expect(formatStarCount(37_600)).toBe("37.6k");
+ expect(formatStarCount(1_234)).toBe("1.2k");
+ expect(formatStarCount(12_300)).toBe("12.3k");
+ });
+
+ it("trims a trailing .0 ('1k', not '1.0k')", () => {
+ expect(formatStarCount(1_000)).toBe("1k");
+ expect(formatStarCount(2_000)).toBe("2k");
+ });
+
+ it("rounds to one decimal like the repo header", () => {
+ expect(formatStarCount(1_949)).toBe("1.9k");
+ expect(formatStarCount(1_990)).toBe("2k");
+ });
+
+ it("formats millions with an 'm' suffix", () => {
+ expect(formatStarCount(1_200_000)).toBe("1.2m");
+ expect(formatStarCount(2_000_000)).toBe("2m");
+ });
+});
diff --git a/apps/web/features/landing/utils/use-github-stars.ts b/apps/web/features/landing/utils/use-github-stars.ts
new file mode 100644
index 000000000..47f700c9f
--- /dev/null
+++ b/apps/web/features/landing/utils/use-github-stars.ts
@@ -0,0 +1,85 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+/**
+ * Live GitHub star count for the landing header's "GitHub" button.
+ *
+ * Fetched client-side on purpose: the badge lives in the shared
+ * {@link LandingHeader}, which renders on every marketing page, so a single
+ * client fetch covers them all without threading a server value through eight
+ * render sites. Each visitor calls the GitHub API from their own IP, which
+ * sidesteps the shared-outbound-IP rate limit that the server-side
+ * `github-release.ts` fetcher has to work around with a PAT.
+ *
+ * The result is memoized at module scope (plus an in-flight promise) so
+ * client-side navigation between landing pages reuses the first fetch instead
+ * of hitting the API again. A failed fetch caches `null` so we don't retry in
+ * a loop; the button just degrades to its plain "GitHub" label.
+ */
+
+const REPO = "multica-ai/multica";
+
+// `undefined` = never fetched; `number` = resolved count; `null` = fetch failed.
+let cachedStars: number | null | undefined;
+let inFlight: Promise | null = null;
+
+async function loadStars(): Promise {
+ if (cachedStars !== undefined) return cachedStars;
+ if (inFlight) return inFlight;
+
+ inFlight = fetch(`https://api.github.com/repos/${REPO}`, {
+ headers: { Accept: "application/vnd.github+json" },
+ })
+ .then((res) => {
+ if (!res.ok) throw new Error(`GitHub API responded ${res.status}`);
+ return res.json() as Promise<{ stargazers_count?: unknown }>;
+ })
+ .then((data) => {
+ const count =
+ typeof data.stargazers_count === "number" ? data.stargazers_count : null;
+ cachedStars = count;
+ return count;
+ })
+ .catch(() => {
+ cachedStars = null;
+ return null;
+ })
+ .finally(() => {
+ inFlight = null;
+ });
+
+ return inFlight;
+}
+
+export function useGithubStars(): number | null {
+ const [stars, setStars] = useState(cachedStars ?? null);
+
+ useEffect(() => {
+ let active = true;
+ void loadStars().then((count) => {
+ if (active && count != null) setStars(count);
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ return stars;
+}
+
+/**
+ * Compact star count matching GitHub's own repo-header style: one decimal
+ * thousands/millions with the trailing ".0" trimmed ("1k", "37.6k", "1.2m").
+ * Counts below 1,000 render exactly. Mirrors GitHub's `toFixed(1)` rounding so
+ * our badge reads the same as the figure on the repo page.
+ */
+export function formatStarCount(n: number): string {
+ if (n >= 1_000_000) {
+ return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
+ }
+ if (n >= 1_000) {
+ return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
+ }
+ return String(n);
+}