O Next.js 16.3 saiu no começo de agosto com a manchete "a maior atualização desde o 16.0". Adotamos na mesma semana em cinco apps em produção, depois passamos um mês convivendo com ele, e nesta semana movemos tudo para o último patch e revisamos cada afirmação do release post. Isto é o que se sustentou, o que não, e o que eu diria a outro CTO antes de ele ativar as flags.
Contexto, para que os números signifiquem algo: rodamos cinco apps Next.js. Três ficam em um monorepo pnpm: um produto administrativo de 56 rotas atrás de auth, um site público para candidatos com formulários e entrevistas com IA, e um pequeno portal para funcionários. Os outros dois são repos separados: um dashboard interno de analytics e um site de marketing em três idiomas com um blog em MDX. Os cinco fazem deploy na Vercel. Os cinco agora rodam com React Compiler e Cache Components.
O que valeu a pena desde o primeiro dia
O cache de build é real. O cache em filesystem do Turbopack para next build vem ativado por padrão no 16.3. Medido nesta semana no último patch, em um laptop: o app de 56 rotas compila em 30 segundos a frio e em 0,8 segundo quando nada mudou. No 16.3.0 o mesmo rebuild com cache levava 6,6 segundos, então a linha de patches melhorou isso oito vezes sem a gente mexer em nada. Os apps menores ficam entre 4 e 7 segundos a frio e meio segundo com cache. O tempo total é maior (a coleta de page data e a geração estática não são cacheadas), mas a etapa de compilação era a parte que nos deixava esperando.
A evicção de memória mudou uma decisão, não um número. Nunca medimos a memória em dev, mas foi por isso que nosso site de marketing finalmente abandonou next dev --webpack como padrão. Sessões longas do Turbopack cresciam até o laptop reclamar; com a evicção ativada por padrão, isso deixou de ser motivo para manter o webpack. O webpack fica como script de fallback, o que traz sua própria lição mais abaixo.
catchError é o error boundary que a gente realmente queria. Um error.tsx clássico engole notFound() e redirect(), e o reset dele só limpa o estado do cliente. O novo boundary de next/error não faz nenhuma das duas coisas: deixa essas duas passarem, e o retry() dele refaz o fetch dos Server Components abaixo dele. Usamos em volta de páginas que fazem fetch no servidor (com o fallback reportando para o Sentry) e, no blog, em volta do corpo MDX, para que um post quebrado vire um bloco de "tente novamente" em vez de uma página em branco.
TypeScript 7 no next build, com uma pegadinha do pnpm. Quatro dos cinco apps fazem type-check com o tsc nativo. O que não faz é o site de marketing, porque ele usa lint com typescript-eslint, cujo parser quebrava com a API do TypeScript 7 quando testamos. Os apps que usam Biome para lint nunca tiveram esse problema. E com o linker isolado do pnpm, o compilador nativo não inclui automaticamente node_modules/@types, então cada app precisou de um array "types" explícito no tsconfig. Dez minutos, mas dez minutos confusos.
Cache Components muda a semântica HTTP, não só a velocidade
Esta é a parte que eu colocaria em negrito em todo guia de migração. Com Cache Components, uma página faz streaming sobre um shell pré-renderizado, e o shell sai com um 200. Se depois sua página chama notFound() enquanto renderiza a parte dinâmica, o status já foi enviado. O usuário vê sua UI de not-found. O Google vê um 200.
Medimos isso no site de marketing antes de corrigir: um slug de blog inexistente, um slug de landing de anúncio inexistente e um slug de página de comparação inexistente respondiam todos 200. Três famílias de rotas inteiras retornando "encontrado" para lixo, justamente na propriedade em que os crawlers são o cliente. A correção segue a orientação do próprio framework: estabelecer a existência antes do primeiro byte. Nosso proxy agora valida os slugs do blog contra um manifest gerado no build (assim ele não fica desatualizado em produção, e carrega as datas de publicação para que posts agendados continuem funcionando), valida os outros dois contra suas tabelas estáticas e reescreve o que não bate para um path que nenhuma rota atende. O router então serve a página de not-found com a nossa marca e um 404 de verdade.
A segunda mudança semântica é que os segment configs acabaram. export const revalidate = 3600, dynamic = "force-static", dynamic = "force-dynamic": nenhum é permitido depois que a flag está ativada. Não é uma renomeação. revalidate vira um escopo 'use cache' com cacheLife('hours'), e tudo que lê o relógio precisa ir para dentro desse escopo. Nosso blog filtra posts pela data de publicação com new Date(); fora de um escopo de cache isso é dado de request e um erro de prerender na hora, dentro dele o relógio é lido uma vez por entrada de cache e os posts agendados ainda aparecem dentro de uma hora. Feito isso, 204 páginas foram pré-renderizadas no build e um segundo acesso a um post caiu de 129 ms para 29 ms.
Root params é o que destrava o i18n, e ainda não fizemos isso
O exemplo de import.meta.glob do release post é um blog lendo arquivos MDX com gray-matter. É literalmente o nosso, então testamos em agosto. Compilou, e transformou as rotas do blog de dinâmicas em totalmente estáticas, o que quebrou o locale em tempo de request do next-intl com 500s em runtime. As leituras síncronas de fs são estruturais até o layout parar de derivar o locale de params.
Esse mesmo layout é o motivo de o site de marketing carregar export const instant = false em toda a árvore de locale: um layout que escolhe <html lang> e as mensagens a partir de params não consegue produzir um shell estático instantâneo. Root params (lang() de next/root-params) mais setRequestLocale é a solução, e é o próximo passo para esse app. Se o seu app é internacionalizado com um segmento [locale], essa migração é o preço das Instant Navigations, e vale a pena colocá-la no orçamento desde o início em vez de descobri-la por meio de instant = false.
Experimental significa experimental
O React Compiler baseado em Rust é a feature que mais me empolgava e a que mais nos custou. Aconteceram duas coisas.
Primeiro, no nosso maior app ele derruba o build por OOM no container padrão da Vercel, uns 25 segundos depois do início de uma compilação a frio. O mesmo app builda bem pelo caminho do Babel (duas vezes mais lento, mas cabe), e os apps menores buildam bem com o port em Rust. Reportamos, um maintainer se envolveu no mesmo dia, e no fim de agosto eles tinham cortado pela metade o pico de alocação do compilador, com um caminho de crescimento quadrático como provável gatilho no nosso código. Esse trabalho está no canary. Rodamos o experimento de novo nesta semana no último patch do 16.3, em um preview deployment, e tivemos o mesmo SIGKILL. Então nossa config mantém um guard por target: port em Rust em todo lugar, exceto nos builds na Vercel daquele app, onde o compilador roda via Babel.
Segundo, o port em Rust perde a classe de escopo jsx-<hash> em elementos renderizados condicionalmente dentro de componentes com estado, então as regras com escopo de <style jsx> param de casar sem aviso. Isso foi para produção com os labels da sidebar invisíveis. Mantivemos um repositório com um repro mínimo (vercel/next.js#96694) e nesta semana rodamos de novo contra três versões: ainda quebrado no último patch do 16.3, corrigido no canary do 16.4. Isso transformou um "talvez já esteja corrigido" em um número de versão, e significa que nossa regra de "nada de styled-jsx com escopo" fica exatamente até o 16.4.
Duas coisas menores da mesma categoria. output: 'standalone' combinado com um adapter quebrava os deploys na Vercel no 16.3.0 (faltava um arquivo de trace); o fix foi mergeado no canary dois dias depois e, até esta semana, ainda não saiu em nenhum patch estável do 16.3. Confira a tag, não o PR. E o guard de config do nosso fallback para webpack originalmente verificava process.argv procurando --webpack, o que nunca bate porque o next.config é avaliado no processo interno de servidor do Next. Um reviewer pegou isso rodando o fallback de verdade. Guard que você nunca exercita é guard que você não tem.
O bug que não tinha nada a ver com o Next.js
Toda a adoção do 16.3 no site de marketing foi revisada, aprovada e mergeada em 4 de agosto. Ela foi mergeada na feature branch sobre a qual estava empilhada, e essa branch nunca mais foi mergeada. Durante um mês o site rodou um binário do 16.3 com webpack em dev, sem compilador e com os segment configs antigos. Nada quebrou, e é exatamente por isso que ninguém percebeu; só descobri nesta semana fazendo grep em main atrás de uma flag que deveria estar lá. Reaplicamos a intenção na mão; um cherry-pick tinha dez arquivos em conflito contra um mês de trabalho de design. PRs empilhados são ok. PRs empilhados cuja base não é main precisam de um lembrete na base.
O que eu diria a outro CTO
- Aplique os patches. O 16.3.3 corrigiu dois advisories críticos de execução remota de código, um deles na otimização de imagens com AVIF, que é o primeiro formato que nosso site de marketing serve. Nada na linha 16.3.x exigiu mudanças de código da nossa parte. Não há motivo para ficar no 16.3.0.
- Meça seus 404 depois de ativar Cache Components. Um
curlpor família de rotas. Se você vir 200 onde espera 404, mova as verificações de existência para o proxy. - Trate
revalidatecomo uma migração. Encontre cada segment config, encontre o que lê o relógio, mova para dentro de um escopo de cache. - Dê a cada flag experimental uma saída de emergência por target e um jeito de testá-la de novo. A nossa é um commit no preview de um pull request e um revert. Seis minutos, e agora sabemos a resposta para este patch.
- Mantenha um repo de repro para cada bug que você reportar. Rodá-lo de novo contra uma versão nova custa dois minutos e responde a pergunta que comentários upstream não conseguem responder.
- Um fix no canary não é um fix no seu build. Confira a release tag.
- Se você é internacionalizado, planeje a migração para root params antes de planejar Instant Navigations. É o mesmo projeto.
O 16.3 é um bom release. Só o cache de build e o error boundary já valeram o upgrade, e Cache Components é o modelo certo para os apps que construímos. Mas o release post descreve um destino, e o caminho até lá passa pelo seu proxy, pelo seu layout de i18n e pelo limite de memória do seu container de build. Saber disso antes de começar é a maior parte do trabalho.