The root layout awaits two dynamic APIs, which opts every route in the app into request-time rendering. No page route is in Next's Full Route Cache as a result.
Verified against the deployed production build — prerender-manifest.json:
ISR dynamicRoutes total: 0
prerendered routes: manifest.json, favicon.ico, llms.txt, robots.txt, child-safety
Five static files, zero page routes. Any export const revalidate = ... currently in the codebase is inert, which has now been mistaken for a live setting twice during review (#1260).
The two reads
apps/web/src/app/layout.tsx:
const theme = (await cookies()).get("theme")?.value; // :81
const isEmbedRoute = ((await headers()).get("x-pathname") ?? "").startsWith("/embed"); // :85
1. headers() — appears to be dead code
isEmbedRoute only gates the Plausible analytics script. It matches paths under /embed, and the sole route there is app/embed/turnstile/route.ts — a route handler, not a page. Route handlers never render the root layout, and this one returns its own self-contained <!doctype html> document. Its header comment records that it was deliberately moved off being a Next.js page (the page variant pulled the whole app shell into a ~76px WebView and iOS WKWebView killed it).
So isEmbedRoute can never be true; the headers() call computes a constant false. There is no page.tsx under any /embed path.
Worth confirming against git history that the check predates that page→route-handler migration, then deleting it. x-pathname is set in middleware.ts:108 and may have other consumers — check before removing the header itself.
2. cookies() — replaceable with a pre-paint script
The theme cookie is used once, at layout.tsx:117:
<body className={theme === Theme.night ? "dark" : ""}>
Replace with an inline <head> script that reads the cookie (or localStorage) and sets the class before first paint, so no server-side cookie read is needed.
Encouraging signals for moving the class to documentElement:
tailwind.config.ts uses darkMode: "class", which matches on any ancestor, so html.dark works.
- No CSS targets
body.dark / body.night — grepped the SCSS/CSS, zero hits.
⚠️ This must be an atomic migration: the class target, the client theme store (core/global-store/modules/global-module.ts:73-74 writes both localStorage and the cookie), the theme switcher, and layout.tsx:117 all have to move together, or you get a stuck-dark / flash-of-wrong-theme bug.
Expected benefit — and its limits
This is a precondition, not a win on its own. It only removes the blanket dynamic marking; each route still has to be independently static to benefit.
Routes that read the auth cookie by design stay dynamic regardless — feed, profile, and (as of #1260) entry, community and wave pages all branch on active_user to decide whether to strip active_votes. The realistic candidates are content-light static routes such as home, tags and about, and that set deserves its own audit rather than an assumption.
Also worth setting expectations: this cuts cache-miss TTFB only. Warm responses are already served from the edge and the origin SSR cache, so this is not expected to move LCP.
Suggested order
- Delete the
headers() read (confirm it is dead first). Cheap and independent.
- Migrate the theme to a pre-paint script, atomically.
- Re-run the build and diff
prerender-manifest.json — confirm which routes actually enter the Full Route Cache, and whether the route table shows ○/● where it previously showed ƒ.
- Only then decide whether the remaining dynamic routes are worth chasing.
Verification
Theme must survive a hard reload with no flash in both modes, the switcher must still persist across navigation and reload, and the build must show real routes leaving ƒ. If step 3 yields nothing, steps 1–2 are still a small correctness/cleanup win but the ISR premise should be dropped rather than pursued further.
The root layout awaits two dynamic APIs, which opts every route in the app into request-time rendering. No page route is in Next's Full Route Cache as a result.
Verified against the deployed production build —
prerender-manifest.json:Five static files, zero page routes. Any
export const revalidate = ...currently in the codebase is inert, which has now been mistaken for a live setting twice during review (#1260).The two reads
apps/web/src/app/layout.tsx:1.
headers()— appears to be dead codeisEmbedRouteonly gates the Plausible analytics script. It matches paths under/embed, and the sole route there isapp/embed/turnstile/route.ts— a route handler, not a page. Route handlers never render the root layout, and this one returns its own self-contained<!doctype html>document. Its header comment records that it was deliberately moved off being a Next.js page (the page variant pulled the whole app shell into a ~76px WebView and iOS WKWebView killed it).So
isEmbedRoutecan never betrue; theheaders()call computes a constantfalse. There is nopage.tsxunder any/embedpath.Worth confirming against git history that the check predates that page→route-handler migration, then deleting it.
x-pathnameis set inmiddleware.ts:108and may have other consumers — check before removing the header itself.2.
cookies()— replaceable with a pre-paint scriptThe theme cookie is used once, at
layout.tsx:117:Replace with an inline
<head>script that reads the cookie (orlocalStorage) and sets the class before first paint, so no server-side cookie read is needed.Encouraging signals for moving the class to
documentElement:tailwind.config.tsusesdarkMode: "class", which matches on any ancestor, sohtml.darkworks.body.dark/body.night— grepped the SCSS/CSS, zero hits.core/global-store/modules/global-module.ts:73-74writes bothlocalStorageand the cookie), the theme switcher, andlayout.tsx:117all have to move together, or you get a stuck-dark / flash-of-wrong-theme bug.Expected benefit — and its limits
This is a precondition, not a win on its own. It only removes the blanket dynamic marking; each route still has to be independently static to benefit.
Routes that read the auth cookie by design stay dynamic regardless — feed, profile, and (as of #1260) entry, community and wave pages all branch on
active_userto decide whether to stripactive_votes. The realistic candidates are content-light static routes such as home, tags and about, and that set deserves its own audit rather than an assumption.Also worth setting expectations: this cuts cache-miss TTFB only. Warm responses are already served from the edge and the origin SSR cache, so this is not expected to move LCP.
Suggested order
headers()read (confirm it is dead first). Cheap and independent.prerender-manifest.json— confirm which routes actually enter the Full Route Cache, and whether the route table shows○/●where it previously showedƒ.Verification
Theme must survive a hard reload with no flash in both modes, the switcher must still persist across navigation and reload, and the build must show real routes leaving
ƒ. If step 3 yields nothing, steps 1–2 are still a small correctness/cleanup win but the ISR premise should be dropped rather than pursued further.