From fdc7c16d840a927c8b09da719333689a41e9f763 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:06:14 +0000 Subject: [PATCH] fix(docs): keep the 404 title after hydration via a metadata export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C --- apps/docs/app/not-found.tsx | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/docs/app/not-found.tsx b/apps/docs/app/not-found.tsx index 0a4c796..fbb783d 100644 --- a/apps/docs/app/not-found.tsx +++ b/apps/docs/app/not-found.tsx @@ -1,4 +1,5 @@ import { i18n } from '@/lib/i18n'; +import type { Metadata } from 'next'; /** * 404 shell for routes that are not under the `[lang]` segment. @@ -87,6 +88,39 @@ const COPY: Record = { ko: '이 페이지를 찾을 수 없습니다.', }; +/** + * The document title, used in two places that have to agree. + * + * The title element below is what the browser shows while the document parses. + * The `metadata` export is what Next's metadata system resolves for this route, + * and that is what stands once React hydrates — without it the root layout's + * `title.default` (`ObjectOS`) wins the moment hydration completes, and a + * bookmark or a history entry for a dead link reads exactly like the site + * index. Both are fed from this one constant so they cannot drift apart. + * + * `absolute` is required: the root layout declares `title.template` + * (`%s | ObjectOS`), and an ordinary string title would be run through it, + * putting the hydrated title out of step with the parsed one again. + */ +const TITLE = `404: ${COPY.en}`; + +/** + * This is a static export and stays one — the route must not acquire a dynamic + * API. `not-found.tsx` is installed by `next-app-loader` as the *page* module + * of the `/_not-found` route, which is why an export here is read at all; it is + * resolved at build time, and the page stays `○` prerendered. + * + * The title is English for every locale. This boundary sits above the `[lang]` + * segment and cannot read the locale (see the note above on why reading it + * dynamically is not viable), so a localized hydrated title is not reachable + * without moving correctness into a script. The script below still localizes + * the title at parse time; after hydration every locale reads the English 404 + * title, which is the same fallback the copy table already applies. + */ +export const metadata: Metadata = { + title: { absolute: TITLE }, +}; + /** * Inlined verbatim into a `script` element, so it must stay free of anything * that could close that element early. Every value it embeds is a compile-time @@ -112,7 +146,7 @@ export default function NotFound() { return ( - 404: This page could not be found. + {TITLE}