params é uma Promise, os metadados fazem merge campo a campo e não em profundidade, PageProps/LayoutProps só existem quando algo os gera, e um script de tema pre-paint tem de ser renderizado no servidor. Três dos quatro falham em silêncio: build verde, tipos verdes, HTML errado. Tudo o que se segue vem de cinco projetos com Next.js 16.2.10 / React 19.2.7 que mantenho.
1. params é uma Promise, e esperar por ela é a metade fácil
A migração em si é mecânica. params é uma Promise desde a 15; o que a 16 faz é remover o acesso síncrono temporário que te deixava ignorar isso. Esta é a assinatura 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() e headers() seguiram o mesmo caminho. 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;O que me custou tempo foi o efeito de segunda ordem. Assim que o layout é async e espera por params, todos os server components abaixo dele continuam a precisar do locale — e não recebem params. A linha que interessa é o comentário sobre a ordem que acabei por escrever nesse mesmo 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);Põe-na a seguir ao primeiro getMessages() e ficas com o locale por omissão renderizado dentro de um URL com o prefixo correto. Sem erro, sem warning.
2. Os metadados fazem merge campo a campo, não em profundidade
Este é o que foi para produção partido e de que não dei conta durante semanas, porque nada na toolchain se queixa.
O sintoma: páginas que declaravam o seu próprio openGraph ficavam sem og:site_name, og:type e og:locale no view-source. Eu tinha esses três no layout acima delas. Assumi que uma página a acrescentar openGraph.title ia ficar por cima.
Não fica. Da própria documentação do Next (node_modules/next/dist/docs/01-app/03-api-reference/04-functions/generate-metadata.md):
Os objetos de metadata exportados a partir de vários segmentos da mesma rota são fundidos de forma superficial para formar o output final de metadata de uma rota. As chaves duplicadas são substituídas com base na sua ordem.
Superficial significa que a chave openGraph é um único valor. Uma página que a define substitui o objeto do layout por inteiro. O mesmo para twitter. O mesmo para alternates — e esse mordeu duas vezes, porque em launch-template/src/app/layout.tsx a raiz declara aí o autodiscovery do RSS:
alternates: {
types: { "application/rss+xml": FEED_URL },
},Por isso, assim que qualquer página acrescentou alternates.canonical — que todas as páginas precisam para o hreflang — deixou cair em silêncio o <link rel="alternate" type="application/rss+xml"> dessa página.
A correção não é lembrares-te. A correção é tornar os campos base impossíveis de perder, pondo-os num helper que todas as declarações espalham. 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",
};
}Usado como openGraph: { ...openGraphBase(lang), title, description, url, images }. O helper de alternates no mesmo ficheiro volta a declarar os types do feed ao lado do canonical, exatamente pela razão acima.
Como apanhar isto da próxima vez: dá para grep. Todos os ficheiros que declarem o campo têm também de referenciar o helper.
fail=0
for f in $(grep -rl "openGraph:" src/app); do
grep -q "openGraphBase" "$f" || { echo "MISSING: $f"; fail=1; }
done
exit $failDoze ficheiros no levain-template declaram openGraph:; os doze espalham a base. Corre-o em CI e o modo de falha deixa de ser "alguém se esqueceu".
3. tsc --noEmit falha num checkout limpo
Sintoma: clone novo, pnpm install, npx tsc --noEmit, e dezanove erros:
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'.A parte confusa é que esses nomes não são importados de lado nenhum, por isso não há dependência em falta para perseguir. São globais. E são gerados. O next-env.d.ts são três linhas de código:
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";Essa terceira linha é a história toda. PageProps e LayoutProps são declarados em .next/types/routes.d.ts — um artefacto de build — juntamente com uma interface ParamMap que lista todas as rotas e os seus params. (O next-env.d.ts é ele próprio gerado e está no gitignore, por isso num clone novo não há nada para ler: o rasto começa num ficheiro que não tens, a apontar para um diretório que não tens.) Ou seja, o props.params desta assinatura recebe o tipo de um ficheiro que não existe até algo o gerar:
export async function generateMetadata(
props: PageProps<"/[locale]/pricing">
): Promise<Metadata> {
const { locale } = await props.params;Confirmei o mecanismo fazendo type-check das mesmas fontes com esse único ficheiro gerado excluído do programa: dezanove TS2304, todos eles PageProps ou LayoutProps, e zero outros erros.
A correção é um comando de primeira classe que eu não sabia que existia:
next typegen && tsc --noEmitO next typegen escreve os tipos das rotas sem correr um build completo. A sua própria documentação diz que existe precisamente porque "os tipos de rotas só eram gerados durante o next dev ou o next build, o que significava que correr tsc --noEmit diretamente não validava os teus tipos de rotas". Se o teu CI corre um job de type-check separado do job de build, esse job ou está a falhar por completo ou está a verificar as tuas rotas contra o que quer que um build anterior tenha deixado em .next/types.
4. O script de tema pre-paint tem de ser renderizado no servidor
Sintoma: um flash do tema errado ao recarregar, para quem tivesse trocado de tema.
A minha primeira versão era um client component com um useEffect que lia a cookie e punha data-theme no <html>. O atributo acaba correto, mas não consegue evitar o flash: um effect corre depois da hydration, vários paints demasiado tarde.
O mecanismo é simples depois de dito: só o markup que o servidor pôs no documento corre antes do primeiro paint. Por isso o componente tem de ser um server component simples que emite um <script> pelado. O booking-template/src/components/layout/ThemeBoot.tsx traz a regra no comentário do cabeçalho:
* 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.O ficheiro inteiro no launch-template são nove linhas — sem "use client", sem imports (o parsing da cookie foi omitido aqui):
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 diretamente no <head> a partir do root layout, ao lado do mesmo tratamento para as fontes e a cor de destaque:
<html lang="en" className={fontVariables} suppressHydrationWarning>
<head>
<ThemeBoot defaultTheme="dark" />Aquele suppressHydrationWarning no <html> e no <body> não é decoração. O script altera atributos no <html> antes de o React sequer olhar para o DOM, por isso o markup do servidor e o do cliente diferem mesmo por design, e de outra forma o React avisaria em cada carregamento.
O padrão por detrás de três dos quatro
O merge dos metadados, a ordem do setRequestLocale e o script de tema falham todos da mesma maneira: o build está verde, os tipos estão verdes, e o que chega ao browser está errado. Nenhum dos três é apanhado por nada aquém de abrir o view-source, recarregar com uma cookie posta, ou correr um grep que escreveste de propósito. Os tipos de rotas gerados são a exceção que confirma a regra — esses falham alto e bom som, só que não no comando que costumas correr. Quando uma framework passa trabalho para ficheiros gerados e funde os metadados de forma superficial, "compilou" deixa de ser prova.
Este é o padrão que vai no launch — demo ao vivo em https://launch.violettadev.com.