seo: return real HTTP 404s from the username catch-all - #2681
Conversation
The [...recipient] route's loading.tsx created a Suspense boundary at the segment. Page() suspends on use(props.params), so React rendered the fallback, flushed the shell with HTTP 200, and only then resolved notFound() — which landed as a NEXT_HTTP_ERROR_FALLBACK;404 marker inside an already-200 response body. Removing the boundary makes the segment's suspension propagate to the shell, so notFound() throws before headers flush and Next sets a real 404 status. The route's render is otherwise synchronous (params guard checks only), and PaymentPage is a client component, so there is no server data fetch left to cover with a fallback.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code-analysis diffPainscore total: 7157.87 → 7157.63 (-0.24) ✅ Resolved (1)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
Defect (T4) — the site cannot return a real 404
src/app/[...recipient]/loading.tsx(5 lines, rendered<PeanutLoading />) created a Suspense boundary on the username catch-all segment.Page()suspends onuse(props.params), so React rendered the fallback, flushed the shell with HTTP 200, and only then resolvednotFound()— which arrived as aNEXT_HTTP_ERROR_FALLBACK;404marker inside an already-committed 200 response.The catch-all owns every unmatched path, so every 404 on peanut.me was a soft 404: HTTP 200 + noindex. That breaks Google's 404 signal, GSC coverage reporting and every external link checker.
Verified on production before the fix:
Fix
Delete
src/app/[...recipient]/loading.tsx. With no boundary at that segment, the suspension propagates to the shell, sonotFound()throws before headers flush and Next sets a real 404 status.Why this is safe to remove rather than replace:
Page()only runs theisReservedRoute/couldBeRecipientguards afteruse(params).PaymentPageis a client component (./client), so no server data fetch is left uncovered; its own loading states are unchanged.cacheComponentsinnext.config.js, and nosrc/app/loading.tsx— this was the only boundary in the route's ancestry, and no middleware sits in front.grep -rn "recipient\]/loading" src→ empty).PeanutLoadingitself stays and is used in 15+ other places.Cost: the loading spinner no longer paints while the (fast, guard-only) server render resolves. That is the price of a correct status code.
Verification evidence
next buildcannot run on the dev box, so everything below is measured against the Vercel Deploy Preview for this PR (peanut-wallet-git-seo-real-404s-squirrellabs.vercel.app, deployment2eSJX7qLtkbjijSfdiSwL8Fq3jF3), compared side by side with production.CI — all green
typecheck,unit,eslint,format,analyze,e2e,Deploy-Preview,Vercel,ci-successall pass on 4726772. The repo'se2ejob ran in CI (it can't run locally — it needs the API on :5000).(a) Garbage URL now returns a real 404
Same path on production is still
HTTP/2 200. Rendered in a real browser, the preview reportsHTTP status: 404and paints the not-found screen —document.querySelector('h1').innerText→"Hmm, we can't find that page.", with the "Take me home" and "Contact support" actions present. (The 404 UI is client-rendered in both prod and preview —not-found.tsxis'use client'— so the visible copy is absent from raw SSR HTML in both. TheNEXT_HTTP_ERROR_FALLBACK;404digest remains in the flight payload; that is Next's normal encoding of a not-found boundary. What changed is the HTTP status.)(b) and (c) Nothing that used to work regressed
Status matrix, production vs this preview:
One row differs, and it is the one we set out to change.
/enis a 308 →/on both — that's the existing default-locale strip, not a regression; followed, it ends athttps://peanut.me/with 200./en/send-money-to/brazil→ 200,<title>Send Money to Brazil — No IOF Tax | Peanut</title>./kkonrad→ 200,<title>kkonrad on Peanut</title>,x-matched-path: /[...recipient]; in-browser it renders the wallet/profile shell (Send / Request / Add / Withdraw nav) and contains none of the 404 copy. Address-shaped and.eth-shaped URLs also still 200.Blast radius is wider than the one path (in the good direction)
The catch-all really does own every unmatched path, so the fix lands everywhere:
All five report
x-matched-path: /[...recipient]on both sides — they were falling through to the catch-all and inheriting its soft 200.Caveats
/qqqqqqqq99is 200 on prod and on the preview:couldBeRecipient()accepts it, so the server renders the profile shell and only the client discovers the user doesn't exist. Fixing that needs a server-side username lookup in the route; it is out of scope here and is what the blanketnoindexon this route (T3) covers in the meantime.next buildcannot run on the dev box (earlyoom SIGTERMs it under memory contention), andpnpm installwas OOM-killed there too — so no local build or local gate output backs this PR. CI's typecheck/unit/eslint/e2e and the preview curls above are the whole proof. The change is a file deletion with no importers (grep -rn "recipient\]/loading" src→ empty), which is what makes that acceptable.loading.tsxis a runtime-behaviour change that no unit test covers. The curl matrix above is the regression test; please re-run it against production after merge.src/app/[...recipient]/.🤖 Generated with Claude Code