params es una Promise, la metadata se combina campo por campo y no en profundidad, PageProps/LayoutProps solo existen cuando algo los genera, y un script de tema pre-paint tiene que renderizarse en el servidor. Tres de las cuatro fallan en silencio: build verde, tipos verdes, HTML equivocado. Todo lo que sigue sale de cinco proyectos con Next.js 16.2.10 / React 19.2.7 que mantengo.
1. params es una Promise, y esperarla es la mitad fácil
La migración en sí es mecánica. params es una Promise desde la 15; lo que hace la 16 es quitar el acceso síncrono temporal que te dejaba ignorarlo. Esta es la firma real 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() y headers() siguieron el mismo camino. 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;Lo que me costó tiempo fue el efecto de segundo orden. Una vez que el layout es async y espera params, cada server component por debajo sigue necesitando el locale — y no reciben params. La línea que importa es el comentario sobre el orden que terminé escribiendo en ese mismo 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);Ponla después del primer getMessages() y obtienes el locale por defecto renderizado dentro de una URL con el prefijo correcto. Sin error, sin warning.
2. La metadata se combina campo por campo, no en profundidad
Esta es la que salió rota y no lo noté durante semanas, porque nada en el toolchain se queja.
El síntoma: las páginas que declaraban su propio openGraph no tenían og:site_name, og:type ni og:locale en el view-source. Yo tenía esos tres en el layout de más arriba. Asumí que una página que agregaba openGraph.title se iba a superponer.
No lo hace. De la documentación del propio Next (node_modules/next/dist/docs/01-app/03-api-reference/04-functions/generate-metadata.md):
Los objetos de metadata exportados desde varios segmentos de la misma ruta se combinan de forma superficial para formar la salida final de metadata de una ruta. Las claves duplicadas se reemplazan según su orden.
Superficial significa que la clave openGraph es un solo valor. Una página que la define reemplaza el objeto del layout por completo. Lo mismo con twitter. Lo mismo con alternates — y ese mordió dos veces, porque en launch-template/src/app/layout.tsx la raíz declara ahí el autodiscovery del RSS:
alternates: {
types: { "application/rss+xml": FEED_URL },
},Así que en el momento en que cualquier página agregó alternates.canonical — que toda página necesita para el hreflang — esa página perdió en silencio <link rel="alternate" type="application/rss+xml">.
El arreglo no es acordarse. El arreglo es hacer que los campos base no se puedan perder, poniéndolos en un helper que toda declaración expande. 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",
};
}Se usa como openGraph: { ...openGraphBase(lang), title, description, url, images }. El helper de alternates en el mismo archivo vuelve a declarar los types del feed junto al canonical, exactamente por la razón de arriba.
Cómo detectarlo la próxima vez: es grepeable. Todo archivo que declare el campo también tiene que referenciar el helper.
fail=0
for f in $(grep -rl "openGraph:" src/app); do
grep -q "openGraphBase" "$f" || { echo "MISSING: $f"; fail=1; }
done
exit $failDoce archivos en levain-template declaran openGraph:; los doce expanden la base. Córrelo en CI y el modo de falla deja de ser "alguien se olvidó".
3. tsc --noEmit falla en un checkout limpio
Síntoma: clon nuevo, pnpm install, npx tsc --noEmit, y diecinueve errores:
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'.Lo confuso es que esos nombres no se importan de ningún lado, así que no hay dependencia faltante que perseguir. Son globales. Y son generados. next-env.d.ts son tres líneas de código:
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";Esa tercera línea es toda la historia. PageProps y LayoutProps se declaran en .next/types/routes.d.ts — un artefacto de build — junto con una interfaz ParamMap que lista cada ruta y sus params. (next-env.d.ts es a su vez generado y está en el gitignore, así que en un clon nuevo no hay nada que leer: la pista empieza con un archivo que no tienes, apuntando a un directorio que no tienes.) Así que props.params en esta firma toma su tipo de un archivo que no existe hasta que algo lo genera:
export async function generateMetadata(
props: PageProps<"/[locale]/pricing">
): Promise<Metadata> {
const { locale } = await props.params;Confirmé el mecanismo type-chequeando las mismas fuentes con ese único archivo generado excluido del programa: diecinueve TS2304, todos PageProps o LayoutProps, y cero errores adicionales.
El arreglo es un comando de primera clase que no sabía que existía:
next typegen && tsc --noEmitnext typegen escribe los tipos de las rutas sin correr un build completo. Su propia documentación dice que existe precisamente porque "los tipos de rutas solo se generaban durante next dev o next build, lo que significaba que correr tsc --noEmit directamente no validaba tus tipos de rutas". Si tu CI corre un job de type-check separado del job de build, ese job o está fallando de plano o está chequeando tus rutas contra lo que sea que un build anterior haya dejado en .next/types.
4. El script de tema pre-paint tiene que renderizarse en el servidor
Síntoma: un flash del tema equivocado al recargar, para cualquiera que hubiera cambiado de tema.
Mi primera versión era un client component con un useEffect que leía la cookie y ponía data-theme en <html>. El atributo termina correcto, pero no puede evitar el flash: un effect corre después de la hydration, varios paints demasiado tarde.
El mecanismo es simple una vez enunciado: solo el markup que el servidor puso en el documento corre antes del primer paint. Así que el componente tiene que ser un server component plano que emita un <script> pelado. booking-template/src/components/layout/ThemeBoot.tsx lleva la regla en el comentario de su cabecera:
* 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.El archivo completo en launch-template son nueve líneas — sin "use client", sin imports (el parseo de la cookie va elidido aquí):
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 }} />;
}Montado directamente en <head> desde el root layout, junto al mismo tratamiento para las fuentes y el color de acento:
<html lang="en" className={fontVariables} suppressHydrationWarning>
<head>
<ThemeBoot defaultTheme="dark" />Ese suppressHydrationWarning en <html> y <body> no es decoración. El script muta atributos en <html> antes de que React siquiera mire el DOM, así que el markup del servidor y el del cliente difieren de verdad por diseño, y si no React advertiría en cada carga.
El patrón detrás de tres de las cuatro
El merge de la metadata, el orden de setRequestLocale y el script de tema fallan todos de la misma manera: el build está verde, los tipos están verdes, y lo que le llega al navegador está mal. Ninguno de los tres se detecta con nada menos que abrir el view-source, recargar con una cookie puesta, o correr un grep que escribiste a propósito. Los tipos de rutas generados son la excepción que confirma la regla — esos sí fallan a los gritos, solo que no en el comando que corres normalmente. Cuando un framework mueve trabajo a archivos generados y combina la metadata de forma superficial, "compiló" deja de ser evidencia.
Este es el patrón que viene en launch — demo en vivo en https://launch.violettadev.com.