Skip to content

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

Merged
Flotapponnier merged 1 commit into
devfrom
fix/compare-ssr-canonical
Jun 21, 2026
Merged

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

Conversation

@Flotapponnier

Copy link
Copy Markdown
Collaborator

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 (alphabetical ordering mismatch) or where either provider didn't resolve. With no per-page metadata, Next.js used the root layout title.default and description, so crawlers saw the homepage shell on every broken /compare URL.

  2. The page calls redirect() (for non-canonical slugs) and notFound() (for 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. Curl, Googlebot and any other non-JS client only see that skeleton.

The combined effect: /compare/A-vs-B and /compare/B-vs-A (and every ad-hoc invalid pair) all returned 200 with identical empty content and the homepage metadata. Pure URL duplication for indexing.

Fix

Two changes:

  1. src/middleware.ts matches /compare/:slug* and 308s any non-canonical <a>-vs-<b> slug to the alphabetical canonical form, at the edge, before any rendering starts. This handles both reverse-pair URLs (base-vs-arbitrum -> arbitrum-vs-base) and arbitrary input ordering (mobula-vs-coingecko -> coingecko-vs-mobula). 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 now runs the same gating as the page render: it calls notFound() instead of returning {} when the pair fails to resolve, and it also 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, instead of falling through to the root layout defaults.

The page render keeps its own redirect() / notFound() calls as belt-and-suspenders.

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 35a2eda into dev Jun 21, 2026
1 check passed
@Flotapponnier
Flotapponnier deleted the fix/compare-ssr-canonical branch June 21, 2026 13:02
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