params è una Promise, i metadata si fondono campo per campo e non in profondità, PageProps/LayoutProps esistono solo quando qualcosa li genera, e uno script di tema pre-paint deve essere renderizzato lato server. Tre dei quattro falliscono in silenzio: build verde, tipi verdi, HTML sbagliato. Tutto quello che segue viene da cinque codebase Next.js 16.2.10 / React 19.2.7 che mantengo.
1. params è una Promise, e fare await è la metà facile
La migrazione in sé è meccanica. params è una Promise dalla 15; quello che fa la 16 è rimuovere l'accesso sincrono temporaneo che permetteva di ignorarlo. Ecco la firma reale, presa da 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() e headers() hanno fatto la stessa fine. Da 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;Quello che mi è costato tempo è l'effetto di secondo ordine. Una volta che il layout è async e fa await su params, ogni server component sotto di esso ha comunque bisogno della locale — e loro non ricevono params. La riga che conta è il commento sull'ordine che ho finito per scrivere in quello stesso 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);Mettila dopo il primo getMessages() e ti ritrovi la locale di default renderizzata dentro un URL con il prefisso invece corretto. Nessun errore, nessun warning.
2. I metadata si fondono campo per campo, non in profondità
Questo è quello che è andato in produzione rotto e che non ho notato per settimane, perché niente nella toolchain protesta.
Il sintomo: le pagine che dichiaravano il proprio openGraph non avevano og:site_name, og:type e og:locale nel view-source. Quei tre li avevo nel layout sopra di esse. Davo per scontato che una pagina che aggiunge openGraph.title si sarebbe sovrapposta.
Non è così. Dalla documentazione di Next stessa (node_modules/next/dist/docs/01-app/03-api-reference/04-functions/generate-metadata.md):
Gli oggetti Metadata esportati da più segmenti nella stessa route vengono fusi in modo superficiale per formare l'output metadata finale della route. Le chiavi duplicate vengono sostituite in base al loro ordine.
Superficiale significa che la chiave openGraph è un solo valore. Una pagina che la imposta sostituisce in blocco l'oggetto del layout. Lo stesso per twitter. Lo stesso per alternates — e quello mi ha morso due volte, perché in launch-template/src/app/layout.tsx la root ci dichiara l'autodiscovery RSS:
alternates: {
types: { "application/rss+xml": FEED_URL },
},Quindi nel momento in cui una pagina aggiungeva alternates.canonical — che a ogni pagina serve per l'hreflang — quella pagina perdeva silenziosamente <link rel="alternate" type="application/rss+xml">.
La soluzione non è ricordarselo. La soluzione è rendere i campi di base impossibili da perdere, mettendoli in un helper di cui ogni dichiarazione fa lo spread. 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",
};
}Usato così: openGraph: { ...openGraphBase(lang), title, description, url, images }. L'helper alternates nello stesso file ridichiara i types del feed accanto al canonical, esattamente per il motivo di cui sopra.
Come beccarlo la prossima volta: è greppabile. Ogni file che dichiara il campo deve anche referenziare l'helper.
fail=0
for f in $(grep -rl "openGraph:" src/app); do
grep -q "openGraphBase" "$f" || { echo "MISSING: $f"; fail=1; }
done
exit $failDodici file in levain-template dichiarano openGraph:; tutti e dodici fanno lo spread della base. Fallo girare in CI e la modalità di fallimento smette di essere «qualcuno se n'è dimenticato».
3. tsc --noEmit fallisce su un checkout pulito
Sintomo: clone fresco, pnpm install, npx tsc --noEmit, e diciannove errori:
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'.La parte che confonde è che quei nomi non sono importati da nessuna parte, quindi non c'è nessuna dipendenza mancante da inseguire. Sono globali. E sono generati. next-env.d.ts è tre righe di codice:
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";Quella terza riga è tutta la storia. PageProps e LayoutProps sono dichiarati in .next/types/routes.d.ts — un artefatto di build — insieme a un'interfaccia ParamMap che elenca ogni route e i suoi params. (next-env.d.ts è a sua volta generato e in gitignore, quindi su un clone fresco non c'è niente da leggere: la pista parte da un file che non hai, e punta a una cartella che non hai.) Quindi props.params in questa firma è tipizzato da un file che non esiste finché qualcosa non lo genera:
export async function generateMetadata(
props: PageProps<"/[locale]/pricing">
): Promise<Metadata> {
const { locale } = await props.params;Ho confermato il meccanismo facendo il type-check degli stessi sorgenti con quell'unico file generato escluso dal programma: diciannove TS2304, tutti PageProps o LayoutProps, e zero altri errori.
La soluzione è un comando di prima classe di cui non conoscevo l'esistenza:
next typegen && tsc --noEmitnext typegen scrive i tipi delle route senza lanciare un build completo. La sua documentazione dice che esiste proprio perché «i tipi delle route venivano generati solo durante next dev o next build, il che significava che lanciare tsc --noEmit direttamente non validava i tipi delle tue route». Se la tua CI esegue un job di type-check separato dal job di build, quel job o sta fallendo del tutto, o sta verificando le tue route contro quello che un build precedente ha lasciato in .next/types.
4. Lo script di tema pre-paint deve essere renderizzato lato server
Sintomo: un flash del tema sbagliato al reload per chiunque avesse cambiato tema.
La mia prima versione era un client component con uno useEffect che leggeva il cookie e impostava data-theme su <html>. L'attributo finisce corretto, ma non può evitare il flash: un effect gira dopo l'hydration, diversi paint troppo tardi.
Il meccanismo è semplice una volta enunciato: solo il markup che il server ha messo nel documento gira prima del primo paint. Quindi il componente deve essere un server component puro che emette uno <script> nudo. booking-template/src/components/layout/ThemeBoot.tsx porta la regola nel commento di intestazione:
* 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.Il file intero in launch-template è nove righe — niente "use client", niente import (il parsing del cookie è omesso qui):
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 }} />;
}Montato direttamente in <head> dal root layout, insieme allo stesso trattamento per i font e il colore d'accento:
<html lang="en" className={fontVariables} suppressHydrationWarning>
<head>
<ThemeBoot defaultTheme="dark" />Quel suppressHydrationWarning su <html> e <body> non è decorazione. Lo script muta gli attributi su <html> prima ancora che React guardi il DOM, quindi il markup del server e quello del client differiscono davvero, per design, e React altrimenti darebbe un warning a ogni caricamento.
Il pattern dietro tre dei quattro
Il merge dei metadata, l'ordine di setRequestLocale e lo script del tema falliscono tutti allo stesso modo: il build è verde, i tipi sono verdi, e quello che arriva al browser è sbagliato. Nessuno dei tre viene beccato da qualcosa che non sia aprire il view-source, ricaricare con un cookie impostato, o lanciare un grep che ti sei scritto apposta. I tipi delle route generati sono l'eccezione che conferma il punto — quelli falliscono rumorosamente, solo non nel comando che lanci di solito. Quando un framework sposta lavoro dentro file generati e fonde i metadata in modo superficiale, «ha compilato» smette di essere una prova.
Questo è il pattern che spedisco in launch — demo live su https://launch.violettadev.com.