Skip to content

fix(docs): keep the 404 title after hydration via a metadata export - #219

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-212-404-title
Aug 26, 2026
Merged

fix(docs): keep the 404 title after hydration via a metadata export#219
hotlong merged 1 commit into
mainfrom
claude/issue-212-404-title

Conversation

@hotlong

@hotlong hotlong commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #212

(Angle-bracket markup omitted throughout — this repo's sanitizer strips it, including inside code fences. "title element" means the HTML element of that name.)

All readings below are from fdc7c16, the head of this branch, taken against next start on a production build, except the two rows explicitly marked next dev. Title behaviour is read in a real Chromium (Playwright, 141.0.7390.37) — curl cannot see this defect, because the whole of it is that the title is correct at parse time and wrong afterwards.

The card's caveat was the right question, and the answer is: the mechanism exists

The grading comment asked whether Next supports a metadata or generateMetadata export from not-found.tsx on the installed version, warned that it historically has not, and asked for the answer from node_modules and a build rather than from documentation. Established both ways:

From node_modules (next@16.2.6). next-app-loader builds the /_not-found entry by installing the user's not-found.tsx as the page module of that route, not as an error boundary:

children: ['_not-found', {
  children: ['__PAGE__', {}, {
    page: [ notFoundN, "…/app/not-found.tsx" ]
  }]
}, {}]

collectMetadata in dist/lib/metadata/resolve-metadata.js reaches it through the ordinary getLayoutOrPageModule(tree) path, and getDefinedMetadata reads mod.metadata / mod.generateMetadata with no filter on module type. So for the unmatched-URL path — the one this card is about — an export here is read exactly like any page's.

From a build. A sentinel export (title.absolute = 'SENTINEL_404_PROBE') was built and the prerendered document read directly. The metadata title changed from ObjectOS to the sentinel, and the browser confirmed the hydrated title followed it. The probe was reverted before the real change; its restore is proven by blob identity against HEAD (0a4c7968…), not by an exit code.

So the option space was not empty, and no client-side title swap was needed. The fix is a static server-side declaration.

The defect, measured

Reproduced on current main (e8b2062), not only on the ffe36c5 the card recorded. The served document has always carried two title elements in the head — the component's own, then Next's metadata title from the root layout's title.default. The browser takes the first while parsing; on hydration React re-inserts the metadata title ahead of it and it wins.

tree title at load title after hydration
main at e8b2062 404: This page could not be found. ObjectOS
this branch 404: This page could not be found. 404: This page could not be found.

A second finding the card did not have, and it is in the same file. #213's script sets document.title for a non-default locale, and that line was being reverted by the same mechanism — measured on e8b2062:

request title at load title after hydration, main
/zh-Hans/no-such-page 404: 找不到此页面。 ObjectOS
/ja/no-such-page 404: このページは見つかりませんでした。 ObjectOS

The lang attribute and the body copy — the reader-facing halves #213 was actually about — survive hydration and are untouched here. Only its title line was inert.

The change

One file, exactly the granted surface. metadata is declared from the same constant that feeds the title element, so the two cannot drift:

const TITLE = `404: ${COPY.en}`;

export const metadata: Metadata = {
  title: { absolute: TITLE },
};

absolute is load-bearing: the root layout declares title.template (%s | ObjectOS), and a plain string title would be run through it, putting the hydrated title out of step with the parsed one again — the same class of defect in a new spelling.

What this does not fix, stated plainly

After hydration the title is English for every locale. This boundary sits above the [lang] segment and cannot read the locale, and the file's own docstring records what reading it dynamically costs (577 prerendered documents down to 1). A localized hydrated title is only reachable by putting correctness into a script, which is precisely the trade the dispatch ruled out — so it is not taken.

This is a strict improvement in every locale regardless: the bookmark and history entry now read as a 404 rather than as the site index, which is the whole of what the card asked for.

Measured across all seven locales, production build:

request at load after hydration lang body copy
/no-such-page 404: This page could not be found. same en English
/zh-Hans/no-such-page 404: 找不到此页面。 404: This page could not be found. zh-Hans 找不到此页面。
/ja/no-such-page 404: このページは見つかりませんでした。 404: This page could not be found. ja localized
/de/no-such-page 404: Diese Seite konnte nicht gefunden werden. 404: This page could not be found. de localized
/es/no-such-page 404: No se ha podido encontrar esta página. 404: This page could not be found. es localized
/fr/no-such-page 404: Cette page est introuvable. 404: This page could not be found. fr localized
/ko/no-such-page 404: 이 페이지를 찾을 수 없습니다. 404: This page could not be found. ko localized

Noted honestly: under next dev the localized title survives hydration in all seven locales. Dev and prod apply metadata differently here. Prod is the behaviour that ships and prod is what the table above records.

The pinned invariants, all re-measured

The route did not go dynamic. /_not-found is still (Static) in the route table.

main at e8b2062 this branch
prerendered HTML files 577 577
static pages 996/996 996/996

Still one document, byte-identical across every unmatched URL#209's property, re-pinned over thirteen URLs. sha256 of the response with script bodies stripped (first 16 hex):

                              main e8b2062        this branch
/no-such-page                 479133a86f456e70    ab06302a8a68046e
/foo.bar/privacy              479133a86f456e70    ab06302a8a68046e
/foo.bar/terms                479133a86f456e70    ab06302a8a68046e
/foo.bar/docs                 479133a86f456e70    ab06302a8a68046e
/foo.bar                      479133a86f456e70    ab06302a8a68046e
/favicon.ico                  479133a86f456e70    ab06302a8a68046e
/1.2.3/privacy                479133a86f456e70    ab06302a8a68046e
/1.2.3/terms                  479133a86f456e70    ab06302a8a68046e
/no/such/deep/path            479133a86f456e70    ab06302a8a68046e
/zz-ZZ/privacy                479133a86f456e70    ab06302a8a68046e
/another-missing              479133a86f456e70    ab06302a8a68046e
/docs/no-such-page            479133a86f456e70    ab06302a8a68046e     -- #182/#192
/zh-Hans/docs/no-such-page    479133a86f456e70    ab06302a8a68046e     -- #182/#192

One hash per column, as required. The hash changes between columns because the title changed — that is the fix. Stripped body goes 1,534 to 1,560 bytes: exactly +26, the difference between ObjectOS and 404: This page could not be found. in the second title element, and nothing else.

The strip is still what makes the reading real. #182's warning reproduces on this tree — raw grep scores healthy 200 pages as containing the 404 copy, because it is in the RSC payload either way:

                        raw grep   stripped
/docs/architecture         1           0     -- healthy page the naive command scores as a 404
/privacy                   1           0     -- ditto
/no-such-page              1           1

Healthy controls, byte-identical to main. Not merely "still 200" — the same sha256 on both sides:

request main this branch
/privacy 200 628e47d088f3f31a identical
/terms 200 f8132a09c44011f7 identical
/docs/architecture 200 2fb0915844412389 identical
/zh-Hans/docs/architecture 200 3a58cd2567861545 identical
/ja/docs/architecture 200 9819833d7f0aa03f identical
/zh-Hans/privacy 200 3ad2ba1a1d1b280d identical
/docs 200 cd095695318ce857 identical

Redirects and the host table, every row unchanged.

request result, both sides
/cn/docs/architecture 308 to /zh-Hans/docs/architecture
/cn/privacy 308 to /zh-Hans/privacy
/ 307 to /docs
Accept-Language: zh-CN on /docs/architecture 307 to /zh-Hans/...
Accept-Language: ja on /docs/architecture 307 to /ja/...
Host: www.objectos.app /docs/architecture 308 to https://docs.objectos.ai/docs/architecture
Host: www.objectos.app / 308 to https://docs.objectos.ai/
Host: www.objectos.app /docs/architecture?q=1 308, query kept
Host: www.objectos.app /zh-Hans/docs/architecture 308, prefix kept

Dotted machine-facing routes, unchanged: /llms.txt 200 (14,672 b), /sitemap.xml 200 (299,145 b), /robots.txt 200 (117 b), /docs/architecture.mdx 200 (12,488 b).

noindex still present on the served 404 — this page still reaches no index, which is why the card was graded cosmetic and why nothing heavier than a static declaration was spent on it.

Zero hydration complaints under next dev across en, zh-Hans, ja, ko: no hydration warning in the browser console and none in the dev server log. The only console entries are the 404 navigation response itself and the HMR WebSocket failing through this container's proxy — both environmental and both present on main.

Gates

All at fdc7c16, run after the final commit, --force on every turbo task because the cache is shared across worktrees in this container and would otherwise replay a sibling's green. Exit code captured before any pipe, and each row reads the command's own verdict line.

gate result
pnpm turbo run type-check --continue --force exit 0 — Tasks: 1 successful, 1 total (script echoed: fumadocs-mdx && next typegen && tsc --noEmit)
pnpm turbo run build --force exit 0 — Compiled successfully in 35.2s, Generating static pages using 3 workers (996/996), 577 prerendered HTML files
pnpm turbo run test --force exit 0 — 3 self-test(s) passed, Tasks: 1 successful, 1 total
node .github/scripts/check-locale-surface.mjs exit 0 — 346/346 sitemap URLs, 0 unexpected, 0 missing; both llms bodies 60/60

Scope

One file, apps/docs/app/not-found.tsx, the granted surface. No changeset — this repo has no changeset flow and no .changeset directory; skip-changeset is not a mechanism here, as #209 recorded. No new findings were filed: the one thing found off the card's face (#213's inert title line) is in this same file and in the same defect class, is fixed by this same change, and is reported above rather than spun out.


Generated by Claude Code

The 404 document rendered a title element reading "404: This page could
not be found.", correct while the document parsed. Once React hydrated,
Next's metadata system applied the root layout's title.default and the
tab read plain "ObjectOS" — a bookmark or history entry for a dead link
was indistinguishable from the site index.

not-found.tsx is installed by next-app-loader as the *page* module of the
/_not-found route, so a metadata export from it is resolved like any
page's. Declaring the title there makes the metadata system emit the 404
title instead of the layout default, so the parsed and hydrated titles
agree. title.absolute is required because the root layout declares a
title.template.

The export is static, so the route stays prerendered: 577 prerendered
HTML documents and 996/996 static pages, unchanged, and the served 404 is
still one document byte-identical across every unmatched URL.

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 06:32
@hotlong
hotlong merged commit e38b2bf into main Aug 26, 2026
2 checks passed
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.

[finding] The 404 document's title reverts to "ObjectOS" once React hydrates, losing the "404:" prefix

2 participants