Skip to content

fix(docs): serve a docs 404 from the prerendered not-found route - #192

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-182-locale-notfound-boundary
Aug 26, 2026
Merged

hotlong merged 1 commit into
mainfrom
claude/issue-182-locale-notfound-boundary

Conversation

@hotlong

@hotlong hotlong commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #182

(Angle-bracket markup omitted throughout — this repo's sanitizer strips it, including inside code fences.)

One line ships: export const dynamicParams = false on apps/docs/app/[lang]/docs/[[...slug]]/page.tsx. A one-line flag needs the reason, because it is not a workaround for the boundary — it removes the render the boundary cannot survive.

Root cause

notFound() raised while a request is being rendered dynamically escapes both the RSC and the SSR render. Next catches it in the SSR error handler and builds the response from getErrorRSCPayload, which seeds the document as an empty error shell and ships the real 404 tree only in the RSC payload, for the client to render after hydration. The status was already a correct 404; the body was blank until client-side React took over, so crawlers and no-JS clients got nothing.

No not-found boundary participates in that server render — at any depth. That was measured, not assumed: a boundary in the locale segment, that same boundary emitting its own document shell, the shell relocated to the root layout above the boundary, and the experimental global-not-found convention were each built and probed, and each left the served bytes unchanged. The card's proposed app/[lang]/not-found.tsx is byte-for-byte a no-op, which is why this PR does not add one.

The path that already worked is not a boundary at all: an unmatched URL such as /no-such-page is served by the prerendered /_not-found route, an ordinary page render. Two different mechanisms, and only one of them ever produced HTML. dynamicParams = false routes an unknown docs slug to that same working route instead of rendering this page so it can throw.

notFound() stays in page.tsx — it still answers a slug that is enumerated but unresolvable, and it is the type-level narrowing for page.

The property change

An unenumerated page now needs a rebuild to appear, rather than rendering on demand. Recorded as PM judgement, in the PM's words:

every page here comes from local MDX enumerated by generateStaticParams, so a page that needs a rebuild to appear is already true in practice — this makes it explicit rather than introducing it. And #190 set the same flag on the OG route hours ago for the same reason. Refusing it here would leave the codebase inconsistent with itself.

Maintainer has a veto window on the card.

Verification

Served over HTTP from production builds, plus next dev. Same-base A/B: both sides built from b078736, the control being this branch with the flag removed, so nothing is attributed across #190.

request control (no flag) this branch
/docs/no-such-page error shell, 404 copy absent from markup, no language declared 404 copy in server markup, lang=en, no error shell
/zh-Hans/docs/no-such-page error shell, 404 copy absent 404 copy in server markup, lang=en
/ja/docs/no-such-page error shell, 404 copy absent 404 copy in server markup
/docs/build/no-such-page error shell, 404 copy absent 404 copy in server markup
/no-such-page correct 404 page unchanged — #168 preserved
/docs, /zh-Hans/docs 200, lang=en / lang=zh-Hans unchanged

Status is 404 on every 404 row, before and after.

  • No live page's rendering changed. Control vs this branch, script bodies stripped: the rendered markup of /docs, /zh-Hans/docs, /no-such-page and /zh-Hans/no-such-page is identical. The only byte that differs anywhere in those documents is Next's per-build id ("b":"CGHSq3fMjA5qkNUR8HTIL" against "b":"aPwufDC26x71NejjSd_yL"), same length, everything else equal.
  • Static page count holds at 577 — control 577, this branch 577.
  • next dev gives the same result, and no missing-root-layout validator break. The only dev warning is the pre-existing middleware-deprecation notice, present on main too.
  • Config redirects still honoured: /docs/build/data-model and /zh-Hans/docs/build/flows both 308 to their new paths.
  • CI gates run locally, all green: pnpm turbo run type-check (script name echoed, so it really ran), pnpm turbo run build, pnpm turbo run test.

Reproducing this — the card's original command was wrong

A raw grep over the response cannot tell server-rendered markup from the streamed RSC payload, and reports this defect as already fixed. On the broken build it returns 1 for /docs/no-such-page, and it returns 1 for a healthy 200 /docs as well. The 404 copy is in the payload either way. Strip the script bodies first:

cd apps/docs && pnpm run build && npx next start -p 3187

strip() { perl -0777 -pe 's/\x3cscript\b[^\x3e]*\x3e.*?\x3c\/script\x3e//gs'; }

# the defect, and the fix: 0 before, 1 after
curl -s http://localhost:3187/docs/no-such-page | strip | grep -c "could not be found"

# must stay 1 — the path #168 established
curl -s http://localhost:3187/no-such-page | strip | grep -c "could not be found"

# must stay 0 — a healthy page, which the naive command scores as 1
curl -s http://localhost:3187/docs | strip | grep -c "could not be found"

Worth keeping: the next person to verify a 404 on this site will reach for the naive command, and it will tell them everything is fine.

Not in scope

A docs 404 still declares en rather than the locale it was requested under, because /_not-found sits above the locale segment. That half is tracked in #191 and remains open; it is not addressed here.


Generated by Claude Code

A slug that no page matches was rendered dynamically so the page could
call notFound(). That throw escapes both the RSC and the SSR render, and
Next answers from getErrorRSCPayload -- an empty error shell, with the
real 404 tree carried only in the RSC payload for the client. Crawlers
and no-JS clients got a blank body under a correct 404 status.

No not-found boundary participates in that server render, at any depth,
so the fix routes around the dynamic render instead: dynamicParams=false
sends an unknown docs slug to the prerendered /_not-found route, the same
ordinary page render that has always served /no-such-page correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
@hotlong
hotlong marked this pull request as ready for review August 26, 2026 01:02
@hotlong
hotlong merged commit 6653744 into main Aug 26, 2026
2 checks passed
hotlong added a commit that referenced this pull request Aug 26, 2026
…213)

app/not-found.tsx sits above the [lang] segment and hardcoded en, so a reader browsing in their own language got English 404 copy. A synchronous script reads the locale from the first path segment and sets documentElement.lang, the title and the copy together.

Reading a middleware-set header through headers() was built and rejected by measurement: app/not-found.tsx is the root not-found boundary, so a dynamic API in it takes the whole app from 577 prerendered documents to 1. With no prerendered param set left, dynamicParams = false stops rejecting -- /foo.bar/privacy back to 200, /foo.bar/docs back to 500, docs 404s back to the 594-byte empty shell. That undoes #180, #182, #192 and #209 while passing every gate.

The route stays prerendered. The served document remains byte-identical across all eleven unmatched URLs (479133a8...), 577 prerendered files, and the seven healthy control pages are byte-identical once content-hashed asset URLs are masked.

suppressHydrationWarning is required, not decorative: without it React patches the English copy back in. Zero hydration complaints measured under next dev. With scripting disabled the page is byte-for-byte what main serves today.

lang is en between parse and script execution; the page carries noindex and a 404 body is not indexed, so no consumer of parse-time lang exists here.

Closes #191
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.

A 404 under /docs serves a blank error shell, not the 404 page — notFound() from a docs page never renders server-side

2 participants