diff --git a/next.config.ts b/next.config.ts
index dfe2988a..ece0984d 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -121,6 +121,31 @@ const nextConfig: NextConfig = {
},
],
},
+ {
+ // Sitemap: force-dynamic in the route file (Data Cache 2MB cap
+ // blows past for the 500+ URL corpus), so nothing caches it by
+ // default and every crawler hit re-runs the full loader chain.
+ // Hold it edge-side for an hour with SWR so cold hits stay fast.
+ source: "/sitemap.xml",
+ headers: [
+ {
+ key: "Cache-Control",
+ value: "public, s-maxage=3600, stale-while-revalidate=86400",
+ },
+ ],
+ },
+ {
+ // Citable JSON is polled by LLM crawlers (Perplexity, ChatGPT,
+ // Claude Deep Research) — a bare `public` with no s-maxage sent
+ // every scrape to origin. Same freshness window as sitemap.
+ source: "/api/citable",
+ headers: [
+ {
+ key: "Cache-Control",
+ value: "public, s-maxage=3600, stale-while-revalidate=86400",
+ },
+ ],
+ },
];
},
async redirects() {
diff --git a/src/app/benchmarks/[slug]/opengraph-image.tsx b/src/app/benchmarks/[slug]/opengraph-image.tsx
index 361d9c93..4b50596e 100644
--- a/src/app/benchmarks/[slug]/opengraph-image.tsx
+++ b/src/app/benchmarks/[slug]/opengraph-image.tsx
@@ -19,38 +19,17 @@ export async function generateStaticParams() {
return [];
}
-// Emit one OG image per (slug, chain) combo so social shares of
-// `/benchmarks/{slug}?chain=X` render a chain-honest card instead of the
-// cross-chain aggregate leader (which is the misleading baseline-skew
-// case bench-001 ran into: GMGN looking like "fastest crypto data API"
-// on the unfiltered view because Solana's faster baseline drags the
-// average down). The `id` Next.js threads through to the default image
-// handler is the chain value; the unfiltered card uses `id="default"`.
-export async function generateImageMetadata({
- params,
-}: {
- params: Promise<{ slug: string }>;
-}) {
- const { slug } = await params;
- // Load editorial-only via getBenchmark (memoised; safe at build time).
- const b = await getBenchmark(slug);
- const chains = (b?.dimensions?.chain ?? []).filter(
- (c) => c.value !== "all",
- );
- return [
- {
- id: "default",
- alt,
- size,
- contentType,
- },
- ...chains.map((c) => ({
- id: c.value,
- alt: `${alt}. ${c.label}`,
- size,
- contentType,
- })),
- ];
+// Emit a SINGLE OG image for the aggregate bench page. Previously we
+// fanned out one entry per chain variant so `/benchmarks/{slug}?chain=X`
+// social shares could render a chain-honest card, but Next hoists every
+// generateImageMetadata entry into a separate
+// on the parent page — X, LinkedIn, Slack pick nondeterministically
+// among them, so a share of `?chain=solana` might render the `robinhood`
+// card. Chain-scoped pages live at `/benchmarks/[slug]/[chain]` and can
+// carry their own opengraph-image handler if per-chain cards become a
+// priority; today they inherit the site-wide default OG.
+export async function generateImageMetadata() {
+ return [{ id: "default", alt, size, contentType }];
}
export default async function OG({
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index 8b5b5a2d..354c0776 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -4,7 +4,17 @@ import { ArrowLeft } from "lucide-react";
export const metadata: Metadata = {
title: "Page not found",
- robots: { index: false },
+ // Full robots shape so Next replaces (not merges) the layout's robots
+ // meta. Previous shape `{ index: false }` caused two `` tags on prod 404s (layout emitted one from its
+ // own defaults, this route emitted a second) — Bing had been observed
+ // to ignore the duplicated tag and fall through to the layout's
+ // index=true. Match the shape used by the layout's IS_STAGING branch.
+ robots: {
+ index: false,
+ follow: false,
+ googleBot: { index: false, follow: false },
+ },
};
export default function NotFound() {
diff --git a/src/middleware.ts b/src/middleware.ts
index 6c940da7..bf4116b1 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -69,6 +69,17 @@ const COMPARE_PATH = /^\/compare\/([a-z0-9][a-z0-9-]{0,79})\/?$/;
export function middleware(req: NextRequest) {
const { pathname, search } = req.nextUrl;
+ // Case normalisation. `/products/Alchemy` and `/Benchmarks/foo` used
+ // to serve 200 with the mixed-case URL while the HTML canonical
+ // pointed at the lowercase form — Google indexed the mixed-case URL
+ // as a valid variant and burned crawl budget on both. Force lowercase
+ // via 308 so only the canonical shape reaches origin.
+ if (pathname !== pathname.toLowerCase()) {
+ const url = req.nextUrl.clone();
+ url.pathname = pathname.toLowerCase();
+ return NextResponse.redirect(url, 308);
+ }
+
if (search && CANONICAL_NO_QUERY.has(pathname)) {
const canonical = req.nextUrl.clone();
canonical.search = "";
@@ -136,6 +147,11 @@ export const config = {
"/api/llm-context",
"/api/freshness",
"/api/openapi.json",
+ // Wildcard `/((?!_next|.*\..*).*)` covers every HTML route so the
+ // case-normalisation redirect fires on any URL, not just the
+ // enumerated bench/answer/compare/product paths. Excludes _next
+ // internals and any file with an extension (assets, RSS, sitemap).
+ "/((?!_next|api|.*\\..*).*)",
"/benchmarks/:slug*",
"/answers/:slug*",
"/compare/:slug*",