From 490ae0d052d047f7b69f657537af25f2a8d60299 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 21 Jun 2026 14:57:28 +0200 Subject: [PATCH] fix(compare): restore SSR content and canonical, 301 reverse-pair to alphabetical --- src/app/compare/[slug]/page.tsx | 13 +++++++-- src/middleware.ts | 51 +++++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index 3f13018c..dcf68a45 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -107,10 +107,19 @@ export async function generateMetadata({ params: Promise; }): Promise { const { slug } = await params; + // Run the same gating logic as the page render so non-canonical and + // invalid slugs short-circuit at the metadata phase, BEFORE Next.js + // streams the loading.tsx fallback. Without this the SSR HTML ends + // up with the root layout's homepage title and description plus the + // skeleton body, which is exactly what crawlers index. Routing here + // produces a real 308 / 404 response from the route layer instead of + // a 200 wrapping the streamed skeleton. + const canonicalTarget = canonicalisationTarget(slug); + if (canonicalTarget) redirect(`/compare/${canonicalTarget}`); const pair = getComparePair(slug) ?? (await resolveAdHocPair(slug)); - if (!pair) return {}; + if (!pair) notFound(); const { a, b } = await loadPairProviders(pair); - if (!a || !b) return {}; + if (!a || !b) notFound(); const title = `${a.name} vs ${b.name}: live OpenChainBench benchmark data`; const description = capDescription( diff --git a/src/middleware.ts b/src/middleware.ts index b32d35ea..480e28cd 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; /** - * Edge middleware. Three jobs: + * Edge middleware. Four jobs: * * 1. **Cache-key normalisation** on public read-only API routes that * don't use query params. Vercel keys the edge cache on the full URL, @@ -15,15 +15,26 @@ import { NextResponse, type NextRequest } from "next/server"; * on dev / staging only (bridge-revenue, evm-quote-latency, etc.). * On prod they hit `notFound()` and return a 404. Sustained 404 on * previously-indexed URLs gets read as a soft-404 signal that bleeds - * into surrounding bench rankings — likely the dominant driver of - * the recent brand-search collapse. Returning 410 explicitly tells - * Google "this URL is gone for good", and the cluster recovers. + * into surrounding bench rankings, likely the dominant driver of the + * recent brand-search collapse. Returning 410 explicitly tells Google + * "this URL is gone for good", and the cluster recovers. * * Only fires on production (`VERCEL_ENV === "production"`) so that * staging / preview / local dev still render the bench pages from * their YAML files normally. * - * 3. **(future)** any cross-route concerns - kept lightweight. + * 3. **Canonical-order redirect on /compare/-vs-.** Pair pages + * have a single canonical URL: the alphabetical ordering of the two + * provider slugs. Without this, `/compare/base-vs-arbitrum` and + * `/compare/arbitrum-vs-base` both 200 with separate streamed + * Suspense fallbacks, which Google indexes as two empty shells of + * the same comparison. The page component also calls `redirect()` + * on non-canonical slugs but that fires after the loading.tsx + * boundary has streamed its skeleton, so the response goes out as + * 200 with the skeleton HTML and the homepage metadata. Doing it at + * the edge here short-circuits before any render starts. + * + * 4. **(future)** any cross-route concerns. Kept lightweight. */ const CANONICAL_NO_QUERY = new Set([ @@ -45,6 +56,12 @@ const REMOVED_BENCH_SLUGS = new Set([ ]); const BENCH_PATH = /^\/benchmarks\/([a-z0-9][a-z0-9-]{0,79})\/?$/; +// `/compare/-vs-` with both sides as standard provider slug +// shapes (lowercase alphanumeric + hyphens). The `-vs-` delimiter is +// matched literally; provider slugs themselves can contain hyphens +// (e.g. `helius-sender`, `phantom-perps`), so split on the first +// occurrence at parse time, not on every `-`. +const COMPARE_PATH = /^\/compare\/([a-z0-9][a-z0-9-]{0,79})\/?$/; export function middleware(req: NextRequest) { const { pathname, search } = req.nextUrl; @@ -65,9 +82,32 @@ export function middleware(req: NextRequest) { } } + const compareMatch = pathname.match(COMPARE_PATH); + if (compareMatch) { + const canonical = canonicalComparePath(compareMatch[1]); + if (canonical && canonical !== compareMatch[1]) { + const url = req.nextUrl.clone(); + url.pathname = `/compare/${canonical}`; + return NextResponse.redirect(url, 308); + } + } + return NextResponse.next(); } +/** Returns the alphabetical canonical form of a `-vs-` slug, or + * null when the slug isn't a valid pair shape. When the slug is already + * canonical the return value equals the input. */ +function canonicalComparePath(slug: string): string | null { + const idx = slug.indexOf("-vs-"); + if (idx <= 0) return null; + const a = slug.slice(0, idx); + const b = slug.slice(idx + "-vs-".length); + if (!a || !b || a === b) return null; + const [first, second] = [a, b].sort(); + return `${first}-vs-${second}`; +} + export const config = { matcher: [ "/api/citable", @@ -75,5 +115,6 @@ export const config = { "/api/freshness", "/api/openapi.json", "/benchmarks/:slug*", + "/compare/:slug*", ], };