Next.js 16 + React 19: o que aprendi a construir o Descomplicai
Um tour técnico honesto pelo stack do Descomplicai: App Router, Server Components, Partial Pre-rendering, o que funcionou, os gotchas de hydration e streaming, e as decisões que mudaram a performance.
Há uma diferença entre ler documentação sobre uma tecnologia e construir algo real com ela. A documentação mostra o caminho feliz. A realidade mostra os gotchas, as decisões a meio do projecto, e as horas a debuggar um erro que não está na documentação.
O Descomplicai foi construído em Next.js 16 com React 19 desde o início. Este post é o que eu gostaria de ter lido antes de começar.
A decisão de ir full App Router
A primeira decisão estrutural foi usar exclusivamente o App Router. sem pages directory, sem rotas mistas. No início pareceu arriscada porque ainda havia posts de 2024 a desaconselhar para produção. Mas a direcção era clara: o App Router é o futuro do Next.js e o Pages Router está em modo de manutenção.
A estrutura ficou assim:
src/
app/
(marketing)/
page.tsx # homepage
about/page.tsx
blog/
page.tsx # lista de posts
[slug]/page.tsx # post individual
(app)/
projetos/page.tsx
mente/page.tsx
salao-motivacional/page.tsx
support/page.tsx
api/
contact/route.ts
newsletter/route.ts
components/
lib/Os route groups (marketing) e (app) permitem layouts diferentes sem afectar os URLs. Foi uma das primeiras coisas que mudou como penso sobre organização de código em Next.js.
Server Components: a mudança mental que demora
React 19 com Server Components foi o ajuste mais difícil. Conceptualmente é simples: o componente corre no servidor, não envia JavaScript para o cliente, pode aceder directamente a bases de dados.
O problema é que a intuição que desenvolves com React ao longo de anos vai contra isso. A primeira semana estava constantemente a adicionar 'use client' a componentes que não precisavam.
A heurística que acabou por funcionar: começa tudo como Server Component e só moves para Client Component quando há uma razão concreta. interactividade, estado local, acesso a browser APIs, event listeners.
// Server Component: corre no servidor, zero JS no cliente
// src/app/blog/[slug]/page.tsx
import { getPostBySlug } from '@/lib/blog'
import { MDXRemote } from 'next-mdx-remote/rsc'
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug) // acesso directo ao filesystem
return (
<article>
<h1>{post.title}</h1>
<MDXRemote source={post.content} />
</article>
)
}// Client Component: necessário para interactividade
// src/components/NewsletterForm.tsx
'use client'
import { useState } from 'react'
export function NewsletterForm() {
const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
// ...handler com estado e eventos
}Partial Pre-rendering: o que é e quando importa
O PPR foi uma das features que mais expectativa criou no Next.js 15/16. A ideia é elegante: renderizar estaticamente a estrutura da página no build, mas deixar "buracos" dinâmicos que são preenchidos em streaming sem bloquear o HTML inicial.
Para o Descomplicai, o ganho real foi nas páginas que têm conteúdo estático mas precisam de um componente dinâmico. o contador de projectos, dados do utilizador no header se estiver autenticado.
Para activar:
// next.config.ts
const nextConfig = {
experimental: {
ppr: true,
},
}// A parte estática renderiza no build
// O Suspense boundary marca o "buraco" dinâmico
import { Suspense } from 'react'
import { ProjectCount } from '@/components/ProjectCount'
export default function HomePage() {
return (
<main>
<h1>Descomplicai</h1>
<p>IA acessível para Portugal.</p>
{/* Este componente é dinâmico: renderiza em streaming */}
<Suspense fallback={<span>...</span>}>
<ProjectCount />
</Suspense>
</main>
)
}O resultado: o HTML estático chega ao browser instantaneamente, o componente dinâmico aparece em streaming.
O gotcha de hydration que me custou horas
Conteúdo que difere entre o HTML renderizado no servidor e o que o React renderiza no cliente causa um erro de hydration. O mais comum é usar Date.now() ou Math.random() directamente num componente, ou aceder a window/localStorage sem guard.
// ERRADO: causa hydration mismatch
function Component() {
return <div>{new Date().toLocaleDateString('pt-PT')}</div>
}
// CERTO: garante consistência servidor/cliente
'use client'
import { useState, useEffect } from 'react'
function Component() {
const [dateStr, setDateStr] = useState('')
useEffect(() => {
setDateStr(new Date().toLocaleDateString('pt-PT'))
}, [])
if (!dateStr) return null // ou um skeleton
return <div>{dateStr}</div>
}Mas a versão ainda melhor é perguntar: este valor precisa mesmo de ser dinâmico no cliente? Se a data de publicação de um post é estática, formata-a no Server Component e acabou.
Streaming e Suspense: a diferença que se sente
Em vez de esperar que toda a página esteja pronta para enviar HTML, o servidor envia o que tem e vai completando. Para o blog, a lista de posts carrega instantaneamente. Para páginas com dados externos, o utilizador vê a estrutura imediatamente e os dados aparecem quando chegam.
// src/app/projetos/page.tsx
import { Suspense } from 'react'
import { ProjectGrid } from '@/components/ProjectGrid'
import { ProjectSkeleton } from '@/components/ProjectSkeleton'
export default function ProjectsPage() {
return (
<div>
<h1>Projectos</h1>
<Suspense fallback={<ProjectSkeleton />}>
{/* Este componente faz fetch de dados: chega em streaming */}
<ProjectGrid />
</Suspense>
</div>
)
}O skeleton não é só cosmético. Comunica ao utilizador que algo está a chegar e reduz a percepção de lentidão.
Performance: o que fez diferença
Os Core Web Vitals ficaram em boa forma por três razões:
1. Server Components reduzem drasticamente o JavaScript enviado ao cliente. Num site como o Descomplicai, onde grande parte do conteúdo é editorial, isto é significativo.
2. `next/image` com lazy loading por defeito. Todas as imagens optimizadas automaticamente, servidas em WebP, com dimensões correctas. Zero configuração adicional.
3. Font optimisation automática. O Next.js descarrega e serve fontes localmente, eliminando o round-trip para servidores externos.
// Fontes: hosted localmente pelo Next.js, zero round-trip externo
import { Inter, JetBrains_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
})
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
})O que mudaria se recomeçasse
Definiria os boundaries Server/Client Component mais cedo. Passei tempo a refactorizar componentes que cresceram como Client Components desnecessariamente.
Usaria mais `generateStaticParams` para o blog. O conteúdo não muda após publicação. Não há razão para renderizar no servidor a cada pedido.
// Gera todas as páginas de posts estaticamente no build
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}Investiria mais cedo em error boundaries. O streaming torna os erros mais localizados, mas também mais silenciosos sem error boundaries bem posicionados.
Vale a pena?
Sim. Sem hesitação.
O App Router tem uma curva de aprendizagem real. especialmente a transição mental para Server Components. Mas uma vez interiorizado, o modelo é mais limpo, mais performativo, e mais alinhado com como a web devia funcionar.
O Descomplicai está em produção no Vercel e funciona bem. O stack técnico completo está documentado em /projetos.
Se estás a migrar de Pages Router ou a começar um projecto novo, o App Router é o caminho. O investimento inicial paga-se rapidamente.
Tambem podes gostar de:
Descomplicai
Tens questoes ou um projeto em mente?
Envia uma mensagem: respondo a todas.