params est une Promise, les metadata fusionnent champ par champ et non en profondeur, PageProps/LayoutProps n'existent qu'une fois que quelque chose les génère, et un script de thème pre-paint doit être rendu côté serveur. Trois des quatre échouent en silence : build vert, types verts, HTML faux. Tout ce qui suit vient de cinq codebases Next.js 16.2.10 / React 19.2.7 que je maintiens.
1. params est une Promise, et l'await n'est que la moitié facile
La migration en elle-même est mécanique. params est une Promise depuis la 15 ; ce que fait la 16, c'est supprimer l'accès synchrone temporaire qui permettait de l'ignorer. Voici la vraie signature, tirée de 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() et headers() ont pris le même chemin. Extrait de 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;Ce qui m'a coûté du temps, c'est l'effet de second ordre. Une fois que le layout est async et await params, chaque server component en dessous a toujours besoin de la locale — et eux ne reçoivent pas params. La ligne qui compte, c'est le commentaire sur l'ordre que j'ai fini par écrire dans ce même 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);Placez-la après le premier getMessages() et vous obtenez la locale par défaut rendue à l'intérieur d'une URL pourtant correctement préfixée. Aucune erreur, aucun warning.
2. Les metadata fusionnent champ par champ, pas en profondeur
C'est celui qui est parti en prod cassé et que je n'ai pas vu pendant des semaines, parce que rien dans la toolchain ne râle.
Le symptôme : les pages qui déclaraient leur propre openGraph n'avaient ni og:site_name, ni og:type, ni og:locale dans le view-source. J'avais ces trois-là dans le layout au-dessus d'elles. Je supposais qu'une page ajoutant openGraph.title viendrait se superposer.
Ce n'est pas le cas. Extrait de la doc de Next elle-même (node_modules/next/dist/docs/01-app/03-api-reference/04-functions/generate-metadata.md) :
Les objets Metadata exportés depuis plusieurs segments d'une même route sont fusionnés de manière superficielle pour former la sortie metadata finale de la route. Les clés dupliquées sont remplacées selon leur ordre.
Superficiel veut dire que la clé openGraph est une seule valeur. Une page qui la définit remplace l'objet du layout en bloc. Pareil pour twitter. Pareil pour alternates — et celui-là m'a mordu deux fois, parce que dans launch-template/src/app/layout.tsx la racine y déclare l'autodiscovery RSS :
alternates: {
types: { "application/rss+xml": FEED_URL },
},Donc dès qu'une page ajoutait alternates.canonical — ce dont chaque page a besoin pour le hreflang — elle supprimait silencieusement <link rel="alternate" type="application/rss+xml"> de cette page.
Le correctif, ce n'est pas de s'en souvenir. Le correctif, c'est de rendre les champs de base impossibles à perdre, en les mettant dans un helper que chaque déclaration 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",
};
}Utilisé comme ceci : openGraph: { ...openGraphBase(lang), title, description, url, images }. Le helper alternates du même fichier redéclare les types du feed à côté du canonical, exactement pour la raison ci-dessus.
Comment l'attraper la prochaine fois : c'est greppable. Tout fichier qui déclare le champ doit aussi référencer le helper.
fail=0
for f in $(grep -rl "openGraph:" src/app); do
grep -q "openGraphBase" "$f" || { echo "MISSING: $f"; fail=1; }
done
exit $failDouze fichiers dans levain-template déclarent openGraph: ; les douze spreadent la base. Lancez-le en CI et le mode de défaillance cesse d'être « quelqu'un a oublié ».
3. tsc --noEmit échoue sur un checkout propre
Symptôme : clone frais, pnpm install, npx tsc --noEmit, et dix-neuf erreurs :
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'.Ce qui déroute, c'est que ces noms ne sont importés de nulle part, il n'y a donc aucune dépendance manquante à traquer. Ce sont des globals. Et ils sont générés. next-env.d.ts fait trois lignes de code :
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";Cette troisième ligne, c'est toute l'histoire. PageProps et LayoutProps sont déclarés dans .next/types/routes.d.ts — un artefact de build — avec une interface ParamMap qui liste chaque route et ses params. (next-env.d.ts est lui-même généré et gitignoré, donc sur un clone frais il n'y a rien à lire : la piste commence par un fichier que vous n'avez pas, qui pointe vers un dossier que vous n'avez pas.) Donc props.params dans cette signature est typé depuis un fichier qui n'existe pas tant que quelque chose ne l'a pas généré :
export async function generateMetadata(
props: PageProps<"/[locale]/pricing">
): Promise<Metadata> {
const { locale } = await props.params;J'ai confirmé le mécanisme en type-checkant les mêmes sources avec ce seul fichier généré exclu du programme : dix-neuf TS2304, tous PageProps ou LayoutProps, et zéro autre erreur.
Le correctif est une commande à part entière dont j'ignorais l'existence :
next typegen && tsc --noEmitnext typegen écrit les types de routes sans lancer un build complet. Sa propre doc dit qu'elle existe précisément parce que « les types de routes n'étaient générés que pendant next dev ou next build, ce qui voulait dire que lancer tsc --noEmit directement ne validait pas vos types de routes ». Si votre CI fait tourner un job de type-check séparé du job de build, ce job est soit carrément en échec, soit en train de vérifier vos routes contre ce qu'un build antérieur a laissé dans .next/types.
4. Le script de thème pre-paint doit être rendu côté serveur
Symptôme : un flash du mauvais thème au rechargement pour quiconque avait changé de thème.
Ma première version était un client component avec un useEffect qui lisait le cookie et posait data-theme sur <html>. L'attribut finit correct, mais ça ne peut pas éviter le flash : un effect s'exécute après l'hydration, plusieurs paints trop tard.
Le mécanisme est simple une fois énoncé : seul le markup que le serveur a mis dans le document s'exécute avant le premier paint. Le composant doit donc être un server component pur qui émet un <script> nu. booking-template/src/components/layout/ThemeBoot.tsx porte la règle dans son commentaire d'en-tête :
* 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.Le fichier entier dans launch-template fait neuf lignes — pas de "use client", pas d'imports (le parsing du cookie est élidé ici) :
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 }} />;
}Monté directement dans <head> depuis le root layout, à côté du même traitement pour les fonts et la couleur d'accent :
<html lang="en" className={fontVariables} suppressHydrationWarning>
<head>
<ThemeBoot defaultTheme="dark" />Ce suppressHydrationWarning sur <html> et <body> n'est pas décoratif. Le script mute des attributs sur <html> avant même que React ne regarde le DOM, donc le markup serveur et le markup client diffèrent réellement, par design, et React warnerait sinon à chaque chargement.
Le point commun de trois des quatre
La fusion des metadata, l'ordre de setRequestLocale et le script de thème échouent tous de la même façon : le build est vert, les types sont verts, et ce que reçoit le navigateur est faux. Aucun des trois n'est attrapé par quoi que ce soit, à moins d'ouvrir le view-source, de recharger avec un cookie posé, ou de lancer un grep que vous avez écrit exprès. Les types de routes générés sont l'exception qui confirme le propos — eux échouent bruyamment, juste pas dans la commande que vous lancez d'habitude. Quand un framework déplace du travail dans des fichiers générés et fusionne les metadata de façon superficielle, « ça a compilé » cesse d'être une preuve.
C'est le pattern que je livre dans launch — démo live sur https://launch.violettadev.com.