Skip to content

fix(compare): restore SSR content and canonical, 301 reverse-pair to alphabetical - #613

Merged
Flotapponnier merged 1 commit into
mainfrom
hotfix/compare-ssr-canonical
Jun 21, 2026
Merged

fix(compare): restore SSR content and canonical, 301 reverse-pair to alphabetical#613
Flotapponnier merged 1 commit into
mainfrom
hotfix/compare-ssr-canonical

Conversation

@Flotapponnier

Copy link
Copy Markdown
Collaborator

Cherry-pick of PR #612 (against dev) to main as a production hotfix.

Root cause

Live SSR audit on /compare/-vs- showed every URL returning HTTP 200 with the homepage title, no canonical, and the loading.tsx skeleton as body. Two problems compounded:

  1. generateMetadata returned an empty {} for any pair where the slug was non-canonical or where either provider didn't resolve. With no per-page metadata, Next.js used the root layout title.default, so crawlers saw the homepage shell on every broken /compare URL.

  2. The page calls redirect() (non-canonical slugs) and notFound() (invalid pairs) inside the page render. Because the route ships a loading.tsx, the entire render is wrapped in a Suspense boundary. Next.js streams the loading skeleton first, then resolves the page. When redirect() or notFound() fires after streaming has started, the public HTML response stays 200 and the body is the skeleton instead of a real 308 / 404.

Combined effect: /compare/A-vs-B and /compare/B-vs-A (and every ad-hoc invalid pair) all returned 200 with identical empty content. Pure URL duplication in the Google index.

Fix

Two changes:

  1. src/middleware.ts matches /compare/:slug* and 308s any non-canonical <a>-vs-<b> slug to the alphabetical canonical, at the edge, before any rendering starts. Middleware fires before the loading.tsx Suspense boundary so the redirect actually goes out as a 308.

  2. src/app/compare/[slug]/page.tsx generateMetadata calls notFound() instead of returning {} when the pair fails to resolve, and runs the canonical redirect as a defensive backup. generateMetadata runs at the route layer (outside the Suspense), so notFound() here correctly sets the not-found.tsx metadata (title + noindex) on the response.

Validation

Local pnpm build plus next start:

  • /compare/arbitrum-vs-base (curated): 200 with Arbitrum vs Base: live OpenChainBench benchmark data · OpenChainBench, self-canonical link, 1 h1.
  • /compare/base-vs-arbitrum (reverse curated): 308 to /compare/arbitrum-vs-base.
  • /compare/mobula-vs-coingecko (non-canonical ad-hoc): 308 to /compare/coingecko-vs-mobula.
  • /compare/helius-vs-quicknode (invalid, quicknode is not a provider): 200 with Page not found · OpenChainBench title + noindex meta.
  • /compare/coingecko-vs-mobula (canonical ad-hoc, no shared benches): 200 with Page not found · OpenChainBench title + noindex meta.

All 22 curated pairs prerendered with unique title, self-canonical, and exactly 1 h1.

@Flotapponnier
Flotapponnier merged commit f6e306b into main Jun 21, 2026
1 check passed
@Flotapponnier
Flotapponnier deleted the hotfix/compare-ssr-canonical branch June 21, 2026 13:02
Flotapponnier added a commit that referenced this pull request Jun 21, 2026
)

PR #613 tried to short-circuit invalid /compare/<a>-vs-<b> pairs via
notFound() in generateMetadata, but Next 16 still streamed the
loading.tsx skeleton with HTTP 200 and the metadata switched to
noindex mid-stream. Crawlers indexed an empty 'Loading live
measurements' shell with noindex across the whole ad-hoc surface.

Two changes:

1. Add hasSharedBenches() to compare-compute (pure set arithmetic on
   already-loaded provider appearances) and call it in
   generateMetadata so a pair like alchemy-vs-helius (both providers
   exist, share zero benches) hits notFound() at the metadata phase.
   Without this the page body's shared.length === 0 check fired only
   after loading.tsx had streamed, so the response was 200 + skeleton
   + a noindex meta tag from not-found.tsx.

2. Drop loading.tsx. With it in place, Next.js streams the skeleton
   first and the eventual notFound() can never demote the status code
   to 404 (response headers are already sent). Without it, invalid
   pairs cleanly return HTTP 404 with the not-found.tsx body and
   curated pairs render in one pass after the data resolves. Cold
   ad-hoc pairs lose the skeleton but the KV pair cache makes
   subsequent hits warm, and curated pairs are prerendered anyway.

Verified locally (pnpm build + pnpm start):

  /compare/alchemy-vs-quicknode   -> 404 Page not found (quicknode missing)
  /compare/mobula-vs-coingecko    -> 308 then 404 (coingecko missing)
  /compare/alchemy-vs-helius      -> 404 Page not found (no shared bench)
  /compare/quicknode-vs-alchemy   -> 308 to canonical
  /compare/binance-vs-bybit       -> 200 real h1, 50 KB content
  /compare/ethereum-vs-solana     -> 200 real h1, 63 KB content
Flotapponnier added a commit that referenced this pull request Jun 21, 2026
…#621) (#622)

PR #613 left valid ad-hoc /compare/<a>-vs-<b> URLs serving HTTP 200 +
a loading skeleton + noindex meta. With loading.tsx in place, Next 16
streams the skeleton with 200 before the page body's notFound() can
demote the status. Plus generateMetadata only checked that both
providers exist, missing the no-shared-bench case.

This hotfix lands the same fix as PR #621 (dev) but inline on main's
older file structure (no extracted compare-compute helper module
yet). hasSharedBenches() is a pure set arithmetic helper on
appearances, no Prom fan out. loading.tsx removal lets notFound()
ship a real 404.

Verified locally:

  /compare/alchemy-vs-quicknode  -> 404 (quicknode missing)
  /compare/mobula-vs-coingecko   -> 308 then 404 (coingecko missing)
  /compare/alchemy-vs-helius     -> 404 (no shared bench)
  /compare/quicknode-vs-alchemy  -> 308 to canonical
  /compare/binance-vs-bybit      -> 200 real h1 (valid ad-hoc)
  /compare/ethereum-vs-solana    -> 200 real h1 (curated)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant