params is a Promise, metadata merges per field and not deeply, PageProps/LayoutProps only exist once something generates them, and a pre-paint theme script has to be server-rendered. Three of the four fail silently: green build, green types, wrong HTML. Everything below is from five Next.js 16.2.10 / React 19.2.7 codebases I maintain.
1. params is a Promise, and awaiting it is the easy half
The migration itself is mechanical. params has been a Promise since 15; what 16 does is remove the temporary synchronous access that let you ignore that. Here is the real signature from launch-template/src/app/[locale]/layout.tsx:
interface LocaleLayoutProps {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params;
if (!hasLocale(LOCALES, locale)) notFound();cookies() and headers() went the same way. From launch-template/src/lib/preview.ts:
export async function getNavOverlay(): Promise<boolean> {
try {
const c = await cookies();
const v = c.get(NAV_OVERLAY_COOKIE)?.value;What cost me time was the second-order effect. Once the layout is async and awaits params, every server component below it still needs the locale — and they do not receive params. The line that matters is the ordering comment I ended up writing in that same layout:
// Required so server components rendered below see the right locale even
// though they don't re-await `params`. Must come BEFORE any other
// `getTranslations()` / `getMessages()` call.
setRequestLocale(locale);Put it after the first getMessages() and you get the default locale rendered inside a correctly-prefixed URL. No error, no warning.
2. Metadata merges per field, not deeply
This is the one that shipped broken and I did not notice for weeks, because nothing in the toolchain complains.
The symptom: pages that declared their own openGraph were missing og:site_name, og:type and og:locale in view-source. I had those three in the layout above them. I assumed a page adding openGraph.title would layer on top.
It does not. From Next's own docs (node_modules/next/dist/docs/01-app/03-api-reference/04-functions/generate-metadata.md):
Metadata objects exported from multiple segments in the same route are shallowly merged together to form the final metadata output of a route. Duplicate keys are replaced based on their ordering.
Shallow means the key openGraph is one value. A page that sets it replaces the layout's object wholesale. Same for twitter. Same for alternates — and that one bit twice, because in launch-template/src/app/layout.tsx the root declares RSS autodiscovery there:
alternates: {
types: { "application/rss+xml": FEED_URL },
},So the moment any page added alternates.canonical — which every page needs for hreflang — it silently dropped <link rel="alternate" type="application/rss+xml"> from that page.
The fix is not to remember. The fix is to make the base fields un-droppable by putting them in a helper that every declaration spreads. levain-template/src/lib/seo.ts:
export function openGraphBase(lang: Lang): {
type: "website";
siteName: string;
locale: string;
} {
return {
type: "website",
siteName: siteConfig.name,
locale: lang === "es" ? "es_CL" : lang === "fr" ? "fr_FR"
: lang === "pt" ? "pt_PT" : "en_US",
};
}Used as openGraph: { ...openGraphBase(lang), title, description, url, images }. The alternates helper in the same file re-declares the feed types alongside the canonical, for exactly the reason above.
How to catch it next time: it is greppable. Every file that declares the field must also reference the helper.
fail=0
for f in $(grep -rl "openGraph:" src/app); do
grep -q "openGraphBase" "$f" || { echo "MISSING: $f"; fail=1; }
done
exit $failTwelve files in levain-template declare openGraph:; all twelve spread the base. Run it in CI and the failure mode stops being "someone forgot".
3. tsc --noEmit fails on a clean checkout
Symptom: fresh clone, pnpm install, npx tsc --noEmit, and nineteen errors:
src/app/[locale]/pricing/page.tsx(20,10): error TS2304: Cannot find name 'PageProps'.
src/app/[locale]/auth/login/layout.tsx(13,10): error TS2304: Cannot find name 'LayoutProps'.The confusing part is that those names are not imported from anywhere, so there is no missing dependency to chase. They are globals. And they are generated. next-env.d.ts is three lines of code:
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";That third line is the whole story. PageProps and LayoutProps are declared in .next/types/routes.d.ts — a build artifact — together with a ParamMap interface listing every route and its params. (next-env.d.ts is itself generated and gitignored, so on a fresh clone there is nothing to read: the trail starts with a file you do not have, pointing at a directory you do not have.) So props.params in this signature is typed from a file that does not exist until something generates it:
export async function generateMetadata(
props: PageProps<"/[locale]/pricing">
): Promise<Metadata> {
const { locale } = await props.params;I confirmed the mechanism by type-checking the same sources with that one generated file excluded from the program: nineteen TS2304, all of them PageProps or LayoutProps, and zero other errors.
The fix is a first-class command I did not know existed:
next typegen && tsc --noEmitnext typegen writes the route types without running a full build. Its own docs say it exists precisely because "route types were only generated during next dev or next build, which meant running tsc --noEmit directly wouldn't validate your route types." If your CI runs a type-check job separate from the build job, that job is either failing outright or checking your routes against whatever an earlier build left in .next/types.
4. The pre-paint theme script has to be server-rendered
Symptom: a flash of the wrong theme on reload for anyone who had switched themes.
My first version was a client component with a useEffect that read the cookie and set data-theme on <html>. The attribute ends up correct, but it cannot avoid the flash: an effect runs after hydration, several paints too late.
The mechanism is simple once stated: only markup the server put in the document runs before first paint. So the component has to be a plain server component that emits a bare <script>. booking-template/src/components/layout/ThemeBoot.tsx carries the rule in its header comment:
* Rules:
* - Must be a plain server component rendering a bare <script> tag.
* - Never use next/script here: React 19 warns on client-rendered inline scripts.
* - Never render this from a client component.The whole file in launch-template is nine lines — no "use client", no imports (cookie parsing elided here):
export function ThemeBoot({ defaultTheme }: { defaultTheme: "dark" | "light" }) {
const code = `(function(){try{ /* read vs-theme cookie */
document.documentElement.setAttribute('data-theme',v);}catch(e){...}})();`;
return <script dangerouslySetInnerHTML={{ __html: code }} />;
}Mounted directly in <head> from the root layout, alongside the same treatment for fonts and accent colour:
<html lang="en" className={fontVariables} suppressHydrationWarning>
<head>
<ThemeBoot defaultTheme="dark" />That suppressHydrationWarning on <html> and <body> is not decoration. The script mutates attributes on <html> before React ever looks at the DOM, so server and client markup genuinely differ by design, and React would otherwise warn on every load.
The pattern behind three of the four
Metadata merging, the setRequestLocale ordering and the theme script all fail the same way: the build is green, the types are green, and what the browser gets is wrong. None of the three is caught by anything short of opening view-source, reloading with a cookie set, or running a grep you wrote on purpose. The generated route types are the exception that proves the point — they do fail loudly, just not in the command you normally run. When a framework moves work into generated files and merges metadata shallowly, "it compiled" stops being evidence.
This is the pattern I ship in launch — live demo at https://launch.violettadev.com.