Skip to content

seo: return real HTTP 404s from the username catch-all - #2681

Closed
0xkkonrad wants to merge 1 commit into
devfrom
seo/real-404s
Closed

seo: return real HTTP 404s from the username catch-all#2681
0xkkonrad wants to merge 1 commit into
devfrom
seo/real-404s

Conversation

@0xkkonrad

@0xkkonrad 0xkkonrad commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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 on use(props.params), so React rendered the fallback, flushed the shell with HTTP 200, and only then resolved notFound() — which arrived as a NEXT_HTTP_ERROR_FALLBACK;404 marker 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:

$ curl -s -o body.html -w "status=%{http_code}\n" https://peanut.me/zzzfake-user-98765
status=200
$ grep -o "NEXT_HTTP_ERROR_FALLBACK;404" body.html
NEXT_HTTP_ERROR_FALLBACK;404

Fix

Delete src/app/[...recipient]/loading.tsx. With no boundary at that segment, the suspension propagates to the shell, so notFound() throws before headers flush and Next sets a real 404 status.

Why this is safe to remove rather than replace:

  • The route's server render is otherwise synchronous — Page() only runs the isReservedRoute / couldBeRecipient guards after use(params).
  • PaymentPage is a client component (./client), so no server data fetch is left uncovered; its own loading states are unchanged.
  • No PPR / cacheComponents in next.config.js, and no src/app/loading.tsx — this was the only boundary in the route's ancestry, and no middleware sits in front.
  • Nothing imports the file (grep -rn "recipient\]/loading" src → empty). PeanutLoading itself 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 build cannot 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, deployment 2eSJX7qLtkbjijSfdiSwL8Fq3jF3), compared side by side with production.

CI — all green

typecheck, unit, eslint, format, analyze, e2e, Deploy-Preview, Vercel, ci-success all pass on 4726772. The repo's e2e job ran in CI (it can't run locally — it needs the API on :5000).

(a) Garbage URL now returns a real 404

$ curl -s -D - -o /dev/null https://peanut-wallet-git-seo-real-404s-squirrellabs.vercel.app/zzzfake-user-98765
HTTP/2 404
content-type: text/html; charset=utf-8
x-matched-path: /[...recipient]

Same path on production is still HTTP/2 200. Rendered in a real browser, the preview reports HTTP status: 404 and 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.tsx is 'use client' — so the visible copy is absent from raw SSR HTML in both. The NEXT_HTTP_ERROR_FALLBACK;404 digest 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:

PATH                                       PROD     PREVIEW
/                                          200      200
/zzzfake-user-98765                        200      404       <-- intended fix
/en                                        308      308
/en/send-money-to/brazil                   200      200
/pt-br/send-money-to/brazil                200      200
/kkonrad                                   200      200
/vitalik.eth                               200      200
/0xB1B1…B1b1                               200      200
/es/argentina                              308      308
/pricing                                   200      200
/careers                                   200      200
/lp/card                                   200      200
/help                                      307      307
/en/help                                   200      200
/setup                                     200      200
/sitemap.xml                               200      200
/robots.txt                                200      200

One row differs, and it is the one we set out to change.

  • /en is a 308 → / on both — that's the existing default-locale strip, not a regression; followed, it ends at https://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:

PATH                                     PROD     PREVIEW
/nonexistent-page-xyz                    200      404
/en/send-money-to/atlantis               200      404
/en/help/not-a-real-article              200      404
/zzzfake-user-98765/1usdc                200      404
/deleteme404test                         200      404

All five report x-matched-path: /[...recipient] on both sides — they were falling through to the catch-all and inheriting its soft 200.

Caveats

  • Username-shaped garbage still returns 200. /qqqqqqqq99 is 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 blanket noindex on this route (T3) covers in the meantime.
  • next build cannot run on the dev box (earlyoom SIGTERMs it under memory contention), and pnpm install was 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.
  • Deleting a loading.tsx is 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.
  • No conflict expected: per the pre-flight scout, no other open PR touches src/app/[...recipient]/.

🤖 Generated with Claude Code

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.
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Aug 12, 2026 1:28pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b0741678-eb00-45b0-9eba-b04555a7a243

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7157.87 → 7157.63 (-0.24)
Findings: -1 net (+0 new, -1 resolved)

✅ Resolved (1)

  • src/app/[...recipient]/loading.tsx:3 — Loading: exported fn missing return type annotation

@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2928 ran, 0 failed, 0 skipped, 35.5s

📊 Coverage (unit)

metric %
statements 66.3%
branches 51.2%
functions 56.4%
lines 67.0%
⏱ 10 slowest test cases
time test
2.1s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
0.8s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.3s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.3s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.2s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.2s src/components/Kyc/__tests__/SumsubNativeSdk.test.tsx › surfaces and reports a failed launch
0.2s src/utils/__tests__/auth-token.test.ts › ignores the guarded marker and falls back to the plain token
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Consolidated into #2685 (one commit per fix, review catches included) at the CTO's request — this PR's evidence and review thread remain the reference for its slice. Branch kept until #2685 merges.

@0xkkonrad 0xkkonrad closed this Aug 12, 2026
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