Next.js — SSR, SSG e Full-Stack React
Ponto chave: Next.js não é “React com rotas”. É um framework full-stack que controla como, quando e onde cada componente é renderizado. Entender as estratégias de rendering, o sistema de caching e os limites entre servidor e cliente é o que separa um dev que “usa Next” de um que domina o framework.
1. Por que Next.js
1.1 Os Problemas que o React Puro Não Resolve
Uma aplicação React com Vite (SPA pura) tem limitações reais em produção:
Problema 1: SEO
SPA renderiza no client → crawler recebe <div id="root"></div>
Google indexa SPAs, mas com delay e inconsistência.
Redes sociais (OG tags) e bots simples não executam JS.
Problema 2: Performance percebida
SPA: baixa HTML vazio → baixa JS bundle → executa → fetch dados → renderiza
SSR: servidor entrega HTML completo → usuário vê conteúdo imediatamente
Diferença de LCP pode ser de segundos em conexões lentas.
Problema 3: Data fetching no client
Waterfalls: componente monta → useEffect → fetch → filho monta → useEffect → fetch
Cada nível da árvore adiciona uma round-trip.
No servidor, o fetch é local (microsegundos vs centenas de ms).
Problema 4: Bundle size
SPA envia TODO o JavaScript para o client.
Uma lib de markdown rendering (50KB) usada em uma página de blog
é baixada por todos os usuários, mesmo os que nunca acessam o blog.
1.2 O que o Next.js Adiciona
React puro (Vite/CRA):
- Client-side rendering only
- Sem routing built-in
- Sem server-side data fetching
- Bundle único (sem code splitting automático por rota)
Next.js:
- Server Components (zero JS no client por padrão)
- SSR, SSG, ISR, PPR — rendering strategy por rota
- File-based routing com layouts aninhados
- API routes / Route Handlers
- Middleware (edge runtime)
- Image/Font/Script optimization
- Caching automático em múltiplas camadas
1.3 App Router vs Pages Router
O Next.js tem dois sistemas de routing que coexistem:
Pages Router (pre-13, ainda suportado):
pages/
index.tsx → /
about.tsx → /about
blog/[slug].tsx → /blog/:slug
api/hello.ts → /api/hello
- getServerSideProps (SSR), getStaticProps (SSG), getStaticPaths
- Tudo é Client Component por padrão
- _app.tsx e _document.tsx para layout global
App Router (13+, padrão atual):
app/
page.tsx → /
about/page.tsx → /about
blog/[slug]/page.tsx → /blog/:slug
api/hello/route.ts → /api/hello
- Server Components por padrão
- Layouts aninhados com state preservado
- Streaming com Suspense
- Server Actions
- Parallel e intercepting routes
Quando usar cada um:
App Router (escolha padrão para projetos novos):
✓ Server Components e bundle size reduzido
✓ Streaming e loading states granulares
✓ Layouts aninhados sem re-render
✓ Server Actions para mutations
Pages Router (ainda válido):
✓ Projetos existentes — migração gradual é possível
✓ Bibliotecas que ainda não suportam RSC
✓ Equipes que já dominam o modelo mental
1.4 Quando NÃO Usar Next.js
Use Vite + React quando:
- A aplicação é um dashboard autenticado (SEO irrelevante)
- Não precisa de SSR/SSG
- Quer controle total sobre bundling e infra
- Equipe pequena e deploy em S3/CDN estático
Use Remix quando:
- Prefere o modelo de web standards (Form, Request, Response)
- Quer progressive enhancement real
- Não quer ficar preso ao ecossistema Vercel
Use Astro quando:
- O site é primariamente conteúdo (blog, docs, marketing)
- Quer zero JS por padrão e islands architecture
2. App Router e File-Based Routing
2.1 Estrutura do Diretório app/
Cada rota no App Router é um diretório dentro de app/. O Next.js reconhece arquivos especiais por convenção de nome:
app/
├── layout.tsx # Layout raiz (obrigatório, wraps todas as páginas)
├── page.tsx # Rota / (home)
├── loading.tsx # UI de loading (Suspense boundary automático)
├── error.tsx # Error boundary automático
├── not-found.tsx # UI para 404
├── global-error.tsx # Error boundary para o layout raiz
│
├── about/
│ └── page.tsx # /about
│
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ ├── page.tsx # /blog/meu-post (rota dinâmica)
│ └── loading.tsx # Loading específico para esta rota
│
├── dashboard/
│ ├── layout.tsx # Layout aninhado (sidebar, header do dashboard)
│ ├── page.tsx # /dashboard
│ ├── settings/
│ │ └── page.tsx # /dashboard/settings (herda layout do dashboard)
│ └── [...catchAll]/
│ └── page.tsx # /dashboard/qualquer/coisa (catch-all)
│
└── api/
└── webhook/
└── route.ts # POST /api/webhook (Route Handler)
2.2 Layout, Page e Template
// app/layout.tsx — Layout raiz (renderiza uma vez, NÃO re-renderiza na navegação)
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: { default: 'Meu App', template: '%s | Meu App' },
description: 'Descrição do app',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="pt-BR">
<body>
<nav>
{/* Navbar persistente — não re-renderiza entre páginas */}
</nav>
<main>{children}</main>
</body>
</html>
)
}
// app/dashboard/layout.tsx — Layout aninhado
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex">
<aside className="w-64">
{/* Sidebar do dashboard — preserva state entre sub-rotas */}
</aside>
<section className="flex-1">{children}</section>
</div>
)
}
Diferenca entre layout.tsx e template.tsx:
layout.tsx:
- Renderiza uma vez e preserva state entre navegações
- Ideal para navbars, sidebars, providers
- children muda, mas o layout não re-monta
template.tsx:
- Re-monta a cada navegação (nova instância)
- Útil para animações de entrada/saída (useEffect roda a cada navegação)
- Fica entre o layout e o page na hierarquia
2.3 Loading, Error e Not Found
// app/dashboard/loading.tsx — Suspense boundary automático
export default function DashboardLoading() {
return <div className="animate-pulse">Carregando dashboard...</div>
}
// O Next.js wrapa o page.tsx automaticamente:
// <Suspense fallback={<Loading />}>
// <Page />
// </Suspense>
// app/dashboard/error.tsx — Error boundary automático
'use client' // Error boundaries PRECISAM ser Client Components
import { useEffect } from 'react'
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log para serviço de monitoramento
console.error(error)
}, [error])
return (
<div>
<h2>Algo deu errado no dashboard</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Tentar novamente</button>
</div>
)
}
// app/not-found.tsx — Página 404 customizada
import Link from 'next/link'
export default function NotFound() {
return (
<div>
<h2>Página não encontrada</h2>
<Link href="/">Voltar para o início</Link>
</div>
)
}
2.4 Route Groups e Organização
Route groups permitem organizar arquivos sem afetar a URL:
app/
├── (marketing)/ # Grupo — NÃO aparece na URL
│ ├── layout.tsx # Layout próprio para marketing
│ ├── page.tsx # /
│ ├── about/page.tsx # /about
│ └── pricing/page.tsx # /pricing
│
├── (dashboard)/ # Grupo — NÃO aparece na URL
│ ├── layout.tsx # Layout próprio com sidebar
│ ├── dashboard/page.tsx # /dashboard
│ └── settings/page.tsx # /settings
│
└── (auth)/ # Grupo para páginas de auth
├── layout.tsx # Layout sem navbar
├── login/page.tsx # /login
└── register/page.tsx # /register
2.5 Parallel Routes e Intercepting Routes
Parallel Routes renderizam múltiplas páginas na mesma view:
app/
├── layout.tsx
├── @analytics/ # Slot "analytics"
│ └── page.tsx
├── @team/ # Slot "team"
│ └── page.tsx
└── page.tsx
// app/layout.tsx — recebe os slots como props
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div>
{children}
<div className="grid grid-cols-2">
{analytics}
{team}
</div>
</div>
)
}
Intercepting Routes permitem mostrar uma rota dentro do contexto atual (ex: modal de foto sem perder o feed):
app/
├── feed/
│ ├── page.tsx # Lista de posts
│ └── (..)photo/[id]/ # Intercepta /photo/[id] mostrando um modal
│ └── page.tsx
├── photo/[id]/
│ └── page.tsx # Página full do photo (acesso direto ou refresh)
Convenção de interceptação:
(.) → mesmo nível
(..) → um nível acima
(..)(..) → dois níveis acima
(...) → raiz do app
3. Server Components vs Client Components
3.1 O Modelo Mental
No App Router, todo componente é Server Component por padrão. Server Components rodam exclusivamente no servidor — o JavaScript deles nunca é enviado ao browser.
Server Component (padrão):
✓ Acessa banco de dados diretamente
✓ Lê arquivos do filesystem
✓ Usa secrets/env vars do servidor
✓ Zero impacto no bundle size do client
✗ Não pode usar useState, useEffect, useRef
✗ Não pode usar event handlers (onClick, onChange)
✗ Não pode usar browser APIs (window, document)
Client Component ('use client'):
✓ Interatividade (state, effects, events)
✓ Browser APIs
✓ Hooks do React
✗ Código vai para o bundle do client
✗ Não pode importar diretamente Server Components
3.2 A Diretiva ‘use client’
'use client' // Esta diretiva marca o BOUNDARY — este componente e tudo que ele
// importa será incluído no bundle do client
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Contagem: {count}
</button>
)
}
Regra fundamental: 'use client' define uma fronteira de serialização. Tudo acima é servidor, tudo abaixo (incluindo imports) é client.
SERVIDOR
─────────────────────────────────────
ServerPage (pode fazer fetch direto)
│
├── ServerHeader (HTML puro, zero JS)
│
├── ClientInteractiveSection ← 'use client' boundary
│ │
│ ├── ClientForm (useState, onChange)
│ └── ClientButton (onClick)
│
└── ServerFooter (HTML puro, zero JS)
─────────────────────────────────────
CLIENT
3.3 Patterns de Composição
Pattern 1: Empurrar ‘use client’ para as folhas
// RUIM: marca a página inteira como Client Component
'use client'
export default function ProductPage() {
const [qty, setQty] = useState(1)
return (
<div>
<h1>Produto X</h1> {/* Estático — não precisava de JS */}
<p>Descrição longa...</p> {/* Estático — não precisava de JS */}
<img src="/product.jpg" /> {/* Estático — não precisava de JS */}
<QuantitySelector qty={qty} onChange={setQty} /> {/* Interativo */}
</div>
)
}
// BOM: só o componente interativo é Client Component
// app/products/[id]/page.tsx — Server Component (padrão)
import { getProduct } from '@/lib/db'
import { QuantitySelector } from './quantity-selector'
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const product = await getProduct(id) // Fetch direto no servidor
return (
<div>
<h1>{product.name}</h1> {/* Zero JS */}
<p>{product.description}</p> {/* Zero JS */}
<img src={product.imageUrl} /> {/* Zero JS */}
<QuantitySelector productId={id} /> {/* Só este é client */}
</div>
)
}
// app/products/[id]/quantity-selector.tsx
'use client'
import { useState } from 'react'
export function QuantitySelector({ productId }: { productId: string }) {
const [qty, setQty] = useState(1)
return (
<div>
<button onClick={() => setQty(q => Math.max(1, q - 1))}>-</button>
<span>{qty}</span>
<button onClick={() => setQty(q => q + 1)}>+</button>
</div>
)
}
Pattern 2: Server Component como children de Client Component
// Client Components NÃO podem importar Server Components,
// mas podem RECEBÊ-LOS como children (ou qualquer prop React.ReactNode)
'use client'
export function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Abrir</button>
{open && <dialog open>{children}</dialog>}
</>
)
}
// Em um Server Component:
import { Modal } from './modal'
import { ProductDetails } from './product-details' // Server Component
export default async function Page() {
return (
<Modal>
{/* ProductDetails roda no servidor, mas é renderizado dentro do Modal */}
<ProductDetails />
</Modal>
)
}
3.4 Serialization Boundary
Props passadas de Server para Client Components precisam ser serializáveis:
Serializável (pode passar como prop):
✓ Primitivos: string, number, boolean, null, undefined
✓ Arrays e objetos simples (sem métodos)
✓ Date (serializado como string)
✓ FormData
✓ React elements (JSX) — incluindo Server Components como children
NÃO serializável:
✗ Funções (exceto Server Actions)
✗ Classes com métodos
✗ Instâncias de Map, Set, WeakMap
✗ Símbolos
✗ Streams, ReadableStream
4. Rendering Strategies
4.1 Visão Geral
Estratégia Quando renderiza HTML gerado Freshness Performance
──────────────────────────────────────────────────────────────────────────
SSG Build time Estático Stale Máxima (CDN)
ISR Build + revalida Estático* Configurável Muito boa
SSR Cada request Dinâmico Sempre fresh Boa
Streaming Cada request Progressivo Sempre fresh Boa (TTFB rápido)
PPR Build + request Híbrido Parcial Excelente
4.2 Static Site Generation (SSG)
No App Router, uma rota é estática por padrão se não usa nada dinâmico:
// app/about/page.tsx — automaticamente SSG
// Sem fetch dinâmico, sem cookies, sem headers → HTML gerado no build
export default function AboutPage() {
return (
<div>
<h1>Sobre nós</h1>
<p>Conteúdo estático gerado no build.</p>
</div>
)
}
Para rotas dinâmicas com SSG, use generateStaticParams:
// app/blog/[slug]/page.tsx
import { getAllPosts, getPost } from '@/lib/posts'
// Gera as rotas estáticas no build
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
4.3 Server-Side Rendering (SSR)
Uma rota se torna SSR (dinâmica) quando usa funções dinâmicas:
// app/dashboard/page.tsx — SSR (renderiza a cada request)
import { cookies, headers } from 'next/headers'
export default async function DashboardPage() {
const cookieStore = await cookies()
const token = cookieStore.get('session')?.value
const headersList = await headers()
const ip = headersList.get('x-forwarded-for')
const data = await fetch('https://api.example.com/dashboard', {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store', // Explicitamente desabilita cache
})
const dashboard = await data.json()
return <DashboardView data={dashboard} />
}
O que torna uma rota dinâmica:
Funções que forçam SSR:
cookies() — leitura de cookies (depende do request)
headers() — leitura de headers (depende do request)
searchParams — prop de page que depende de query string
Opções de fetch:
fetch(url, { cache: 'no-store' }) — desabilita cache
fetch(url, { next: { revalidate: 0 } }) — mesmo efeito
Config explícita:
export const dynamic = 'force-dynamic' — força SSR
export const dynamic = 'force-static' — força SSG
4.4 Incremental Static Regeneration (ISR)
ISR combina o melhor de SSG e SSR: serve HTML estático, mas revalida em background:
// app/products/page.tsx — ISR com revalidação a cada 60 segundos
export const revalidate = 60 // segundos
export default async function ProductsPage() {
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }, // Alternativa: configurar no fetch
})
const data = await products.json()
return (
<ul>
{data.map((p: { id: string; name: string; price: number }) => (
<li key={p.id}>{p.name} — R$ {p.price}</li>
))}
</ul>
)
}
Fluxo ISR (stale-while-revalidate):
Request 1 (build ou primeiro acesso):
→ Renderiza no servidor → salva HTML no cache → serve
Request 2 (dentro de 60s):
→ Serve HTML do cache instantaneamente (CDN hit)
Request 3 (após 60s):
→ Serve HTML stale do cache (usuário não espera)
→ Em background: re-renderiza a página com dados frescos
→ Salva novo HTML no cache
Request 4:
→ Serve o HTML fresco gerado no background
4.5 Streaming com Suspense
Streaming envia HTML progressivamente, melhorando TTFB e UX:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueChart } from './revenue-chart'
import { LatestOrders } from './latest-orders'
import { UserStats } from './user-stats'
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* UserStats é rápido — renderiza no HTML inicial */}
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
<div className="grid grid-cols-2 gap-4">
{/* RevenueChart é lento — streama quando pronto */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* LatestOrders é lento — streama independentemente */}
<Suspense fallback={<OrdersSkeleton />}>
<LatestOrders />
</Suspense>
</div>
</div>
)
}
Fluxo de streaming:
1. Servidor envia HTML com <h1>Dashboard</h1> + skeletons → TTFB rápido
2. UserStats resolve → servidor envia chunk HTML para substituir o skeleton
3. RevenueChart resolve → servidor envia chunk (independente dos outros)
4. LatestOrders resolve → servidor envia último chunk
5. Página completa — cada seção apareceu assim que ficou pronta
4.6 Partial Prerendering (PPR)
PPR é a estratégia mais recente — combina shell estático com partes dinâmicas:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: true, // Habilita Partial Prerendering
},
}
export default nextConfig
// app/product/[id]/page.tsx — PPR
import { Suspense } from 'react'
import { ProductInfo } from './product-info'
import { UserReviews } from './user-reviews'
import { RecommendedProducts } from './recommended'
export default function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
return (
<div>
{/* Parte estática: gerada no build, servida do CDN */}
<ProductInfo params={params} />
{/* Parte dinâmica: renderizada no request, streamed */}
<Suspense fallback={<ReviewsSkeleton />}>
<UserReviews params={params} />
</Suspense>
{/* Parte dinâmica: personalizada por usuário */}
<Suspense fallback={<RecommendedSkeleton />}>
<RecommendedProducts />
</Suspense>
</div>
)
}
PPR combina SSG + Streaming:
- O shell (header, layout, conteúdo estático) é pré-renderizado no build
- As partes dinâmicas (dentro de Suspense) são renderizadas no request
- Resultado: TTFB de um site estático com freshness de SSR
5. Data Fetching e Caching
5.1 Fetch no Server Component
No App Router, data fetching acontece diretamente nos Server Components com async/await:
// app/posts/page.tsx — Server Component (async é suportado naturalmente)
export default async function PostsPage() {
// fetch() no Next.js é estendido com opções de cache
const res = await fetch('https://api.example.com/posts', {
next: {
revalidate: 3600, // ISR: revalida a cada 1 hora
tags: ['posts'], // Tag para revalidação sob demanda
},
})
if (!res.ok) throw new Error('Falha ao carregar posts')
const posts: Post[] = await res.json()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
5.2 As 4 Camadas de Cache
O Next.js tem um sistema de caching em 4 camadas. Entender cada uma evita bugs sutis e comportamentos inesperados:
Camada 1: REQUEST MEMOIZATION (Server, por request)
─────────────────────────────────────────────────────
O que faz: deduplica fetch() idênticos dentro do MESMO request
Escopo: dura apenas um render do servidor
Onde: React estende fetch() para memorizar por URL + options
Cenário: Layout e Page ambos fazem fetch('/api/user')
→ Apenas 1 request HTTP é feito (o segundo retorna do cache em memória)
Não precisa de configuração — é automático.
Só funciona com GET requests via fetch().
Camada 2: DATA CACHE (Server, persistente)
─────────────────────────────────────────────────────
O que faz: armazena respostas de fetch() entre requests e deploys
Escopo: persistente (sobrevive a redeploys na Vercel)
Comportamento padrão: cache HABILITADO (fetch() faz cache por padrão)
Para desabilitar:
fetch(url, { cache: 'no-store' })
fetch(url, { next: { revalidate: 0 } })
Para revalidar:
fetch(url, { next: { revalidate: 3600 } }) — time-based
revalidateTag('posts') — on-demand
revalidatePath('/posts') — on-demand por rota
Camada 3: FULL ROUTE CACHE (Server, persistente)
─────────────────────────────────────────────────────
O que faz: armazena o HTML e RSC Payload de rotas estáticas
Escopo: persistente (gerado no build para SSG, regenerado no ISR)
Rotas estáticas: HTML + RSC payload cacheados no CDN
Rotas dinâmicas: NÃO são cacheadas (SSR a cada request)
Para invalidar: revalidatePath() ou revalidateTag() invalidam
tanto o Data Cache quanto o Full Route Cache.
Camada 4: ROUTER CACHE (Client, em memória)
─────────────────────────────────────────────────────
O que faz: armazena RSC payload de rotas visitadas no browser
Escopo: sessão do usuário (limpa no refresh)
- Prefetch de <Link> armazena no Router Cache
- Navegação para rota já visitada é instantânea (sem request ao servidor)
- Rotas estáticas: prefetch completo
- Rotas dinâmicas: prefetch parcial (só o layout compartilhado)
Para invalidar:
router.refresh() — limpa o Router Cache da rota atual
revalidatePath/Tag — invalida no próximo request
cookies.set/delete — invalida automaticamente (segurança)
5.3 Opting Out de Cache
// Opção 1: no-store no fetch individual
const data = await fetch('https://api.example.com/live-data', {
cache: 'no-store',
})
// Opção 2: segment config (aplica à rota inteira)
export const dynamic = 'force-dynamic'
export const revalidate = 0
// Opção 3: usando funções dinâmicas (cookies, headers, searchParams)
import { cookies } from 'next/headers'
export default async function Page() {
const cookieStore = await cookies() // Torna a rota dinâmica automaticamente
// ...
}
5.4 Revalidação On-Demand
// app/api/revalidate/route.ts — Webhook que revalida cache
import { revalidateTag, revalidatePath } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidation-secret')
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const { tag, path } = await request.json()
if (tag) {
revalidateTag(tag) // Invalida todos os fetch() com essa tag
}
if (path) {
revalidatePath(path) // Invalida a rota inteira
}
return Response.json({ revalidated: true, now: Date.now() })
}
5.5 Data Fetching sem fetch() — unstable_cache
Para acessos a banco de dados (Prisma, Drizzle) que não usam fetch():
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
const getCachedUser = unstable_cache(
async (userId: string) => {
return db.user.findUnique({ where: { id: userId } })
},
['user-by-id'], // Cache key prefix
{
revalidate: 900, // 15 minutos
tags: ['users'], // Tag para revalidação sob demanda
}
)
export default async function UserProfile({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const user = await getCachedUser(id)
return <div>{user?.name}</div>
}
6. Server Actions
6.1 O que São
Server Actions são funções assíncronas que rodam no servidor e podem ser chamadas diretamente do client. Substituem a necessidade de criar API routes para mutations simples.
// A diretiva 'use server' marca a função como uma Server Action
// Pode ser usada em arquivo separado ou inline em Server Components
// app/actions/posts.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
import { z } from 'zod'
const createPostSchema = z.object({
title: z.string().min(3).max(200),
content: z.string().min(10),
})
export async function createPost(formData: FormData) {
const parsed = createPostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
})
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
await db.post.create({
data: {
title: parsed.data.title,
content: parsed.data.content,
},
})
revalidatePath('/posts') // Invalida o cache da lista de posts
}
6.2 Usando com Forms
// app/posts/new/page.tsx — Server Component com form
import { createPost } from '@/app/actions/posts'
export default function NewPostPage() {
return (
<form action={createPost}>
<label htmlFor="title">Título</label>
<input id="title" name="title" type="text" required />
<label htmlFor="content">Conteúdo</label>
<textarea id="content" name="content" required />
<button type="submit">Publicar</button>
</form>
)
}
6.3 Com useActionState (Feedback e Validação)
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions/posts'
// A Server Action precisa receber prevState como primeiro argumento
// quando usada com useActionState
type ActionState = {
error?: Record<string, string[]>
success?: boolean
}
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState<ActionState, FormData>(
async (_prevState, formData) => {
const result = await createPost(formData)
if (result?.error) return { error: result.error }
return { success: true }
},
{ error: undefined, success: undefined }
)
return (
<form action={formAction}>
<input name="title" type="text" required />
{state.error?.title && (
<p className="text-red-500">{state.error.title[0]}</p>
)}
<textarea name="content" required />
{state.error?.content && (
<p className="text-red-500">{state.error.content[0]}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Publicando...' : 'Publicar'}
</button>
{state.success && <p className="text-green-500">Post criado!</p>}
</form>
)
}
6.4 Optimistic Updates
'use client'
import { useOptimistic } from 'react'
import { toggleLike } from '@/app/actions/likes'
type Post = {
id: string
title: string
liked: boolean
likeCount: number
}
export function PostCard({ post }: { post: Post }) {
const [optimisticPost, addOptimistic] = useOptimistic(
post,
(currentPost, _action: 'toggle') => ({
...currentPost,
liked: !currentPost.liked,
likeCount: currentPost.liked
? currentPost.likeCount - 1
: currentPost.likeCount + 1,
})
)
async function handleLike() {
addOptimistic('toggle') // Atualiza UI imediatamente
await toggleLike(post.id) // Server Action em background
// Se falhar, o React reverte automaticamente para o state real
}
return (
<div>
<h2>{optimisticPost.title}</h2>
<button onClick={handleLike}>
{optimisticPost.liked ? '❤️' : '🤍'} {optimisticPost.likeCount}
</button>
</div>
)
}
6.5 Seguranca em Server Actions
'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export async function deletePost(postId: string) {
// SEMPRE valide autenticação em Server Actions
// Elas são endpoints HTTP — qualquer pessoa pode chamá-las
const session = await auth()
if (!session?.user) {
redirect('/login')
}
// Valide autorização
const post = await db.post.findUnique({ where: { id: postId } })
if (post?.authorId !== session.user.id) {
throw new Error('Não autorizado')
}
// Valide input (nunca confie no client)
if (typeof postId !== 'string' || postId.length === 0) {
throw new Error('ID inválido')
}
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
}
7. API Routes e Middleware
7.1 Route Handlers
Route Handlers substituem as API Routes do Pages Router:
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/posts
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const page = parseInt(searchParams.get('page') ?? '1')
const limit = 20
const posts = await db.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
})
return NextResponse.json(posts)
}
// POST /api/posts
export async function POST(request: NextRequest) {
const body = await request.json()
const post = await db.post.create({ data: body })
return NextResponse.json(post, { status: 201 })
}
// app/api/posts/[id]/route.ts — Rota dinâmica
import { NextRequest, NextResponse } from 'next/server'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const post = await db.post.findUnique({ where: { id } })
if (!post) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(post)
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
await db.post.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}
Quando usar Route Handlers vs Server Actions:
Server Actions:
- Mutations disparadas pelo usuário (forms, buttons)
- Integração nativa com revalidation e redirect
- Suporta progressive enhancement (funciona sem JS)
Route Handlers:
- Webhooks de serviços externos
- APIs públicas consumidas por outros clients (mobile, terceiros)
- Streaming responses (SSE, file download)
- Quando precisa de controle fino sobre headers/status codes
7.2 Middleware
Middleware roda no Edge Runtime antes de cada request, ideal para auth, redirects e headers:
// middleware.ts (na raiz do projeto, fora de app/)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Exemplo 1: Redirect baseado em condição
if (pathname === '/old-page') {
return NextResponse.redirect(new URL('/new-page', request.url))
}
// Exemplo 2: Proteção de rotas
const token = request.cookies.get('session')?.value
if (pathname.startsWith('/dashboard') && !token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
// Exemplo 3: Adicionar headers
const response = NextResponse.next()
response.headers.set('x-request-id', crypto.randomUUID())
return response
}
// Matcher: define em quais rotas o middleware roda
export const config = {
matcher: [
// Roda em tudo EXCETO arquivos estáticos e _next
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
7.3 Pattern: Auth com Middleware
// middleware.ts — Pattern completo de autenticação
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyToken } from '@/lib/auth-edge' // Versão Edge-compatible do JWT verify
const publicRoutes = ['/', '/login', '/register', '/about', '/pricing']
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Rotas públicas: não precisa de auth
if (publicRoutes.includes(pathname)) {
return NextResponse.next()
}
// API routes: validação diferente (Bearer token)
if (pathname.startsWith('/api/')) {
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const token = authHeader.split(' ')[1]
const payload = await verifyToken(token)
if (!payload) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
}
// Injeta user info nos headers para os Route Handlers
const response = NextResponse.next()
response.headers.set('x-user-id', payload.sub)
return response
}
// Rotas protegidas: cookie-based auth
const sessionCookie = request.cookies.get('session')?.value
if (!sessionCookie) {
return NextResponse.redirect(
new URL(`/login?redirect=${pathname}`, request.url)
)
}
const payload = await verifyToken(sessionCookie)
if (!payload) {
const response = NextResponse.redirect(new URL('/login', request.url))
response.cookies.delete('session')
return response
}
return NextResponse.next()
}
Limitacoes do Edge Runtime no Middleware:
O Middleware roda no Edge Runtime (V8 isolate, NÃO Node.js).
Não disponível:
✗ fs, path, child_process (APIs do Node.js)
✗ Conexões TCP diretas (banco de dados via driver nativo)
✗ Bibliotecas que dependem de Node.js APIs
Disponível:
✓ fetch() (Web API)
✓ crypto.subtle (Web Crypto)
✓ TextEncoder/TextDecoder
✓ Headers, Request, Response (Web APIs)
✓ Bibliotecas edge-compatible (jose para JWT, por exemplo)
Consequência: validação de JWT no middleware deve usar
jose (Edge-compatible) em vez de jsonwebtoken (Node-only).
8. Deploy e Performance
8.1 Deploy na Vercel
A Vercel é a plataforma criada pelo time do Next.js. O deploy é zero-config:
Vantagens:
- Automatic Edge Network (CDN global)
- Serverless Functions para SSR/API routes
- ISR nativo (Data Cache persistente entre deploys)
- Preview deployments por PR
- Analytics e Speed Insights integrados
Considerações:
- Vendor lock-in para features avançadas (ISR persistente, Edge Middleware)
- Custos escalam com tráfego (serverless pricing)
- Limites de cold start em serverless functions
8.2 Self-Hosting com Docker
// next.config.ts — output standalone para Docker
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone', // Gera um build auto-contido com dependências
}
export default nextConfig
# Dockerfile multi-stage para Next.js
FROM node:20-alpine AS base
# Instala dependências
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Build da aplicação
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Imagem final (mínima)
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copia apenas o necessário do standalone output
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
Resultado do standalone output:
.next/standalone/
├── server.js # Servidor Node.js minimalista
├── node_modules/ # Apenas dependências de produção (tree-shaken)
└── .next/ # Build output
Tamanho típico: ~50MB vs ~500MB+ com node_modules completo
8.3 Otimizacao de Imagens
// O componente Image do Next.js otimiza automaticamente
import Image from 'next/image'
export function ProductImage({ src, alt }: { src: string; alt: string }) {
return (
<Image
src={src}
alt={alt}
width={800}
height={600}
// Otimizações automáticas:
// - Converte para WebP/AVIF
// - Gera srcset responsivo
// - Lazy loading por padrão
// - Previne CLS com width/height
placeholder="blur" // Mostra blur enquanto carrega
blurDataURL="/placeholder.png"
sizes="(max-width: 768px) 100vw, 50vw"
priority={false} // true para imagens above-the-fold (LCP)
/>
)
}
8.4 Bundle Analysis
# Instalar o analyzer
npm install @next/bundle-analyzer
# next.config.ts
import type { NextConfig } from 'next'
import withBundleAnalyzer from '@next/bundle-analyzer'
const nextConfig: NextConfig = {
// ... config
}
export default withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})(nextConfig)
# Rodar análise
ANALYZE=true npm run build
# Abre um relatório visual com o tamanho de cada módulo
Estratégias para reduzir bundle size:
1. Server Components (padrão no App Router)
→ Bibliotecas usadas só no servidor (marked, prisma) = zero client JS
2. Dynamic imports para componentes pesados
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('./chart'), {
loading: () => <ChartSkeleton />,
ssr: false, // Se não precisa de SSR, nem roda no servidor
})
3. Substituir bibliotecas pesadas
moment.js (300KB) → date-fns (tree-shakeable) ou Temporal API
lodash (70KB) → lodash-es (tree-shakeable) ou métodos nativos
4. Barrel file problem
import { Button } from '@/components' // Importa TUDO do barrel
import { Button } from '@/components/button' // Importa só o necessário
Ou configure optimizePackageImports no next.config.ts:
experimental: {
optimizePackageImports: ['@/components', 'lucide-react'],
}
8.5 Core Web Vitals no Next.js
// Monitoramento de Web Vitals
// app/components/web-vitals.tsx
'use client'
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals((metric) => {
// Envia para analytics (ex: Vercel Analytics, DataDog, etc.)
console.log(metric)
// metric.name: 'LCP' | 'FID' | 'CLS' | 'INP' | 'TTFB' | 'FCP'
// metric.value: número (ms para timing, score para CLS)
// metric.rating: 'good' | 'needs-improvement' | 'poor'
})
return null
}
Checklist de performance no Next.js:
LCP (Largest Contentful Paint):
□ Imagens hero com priority={true} no componente Image
□ Fonts com next/font (evita FOUT/FOIT)
□ Preload de dados críticos
□ Evitar client-side fetch para conteúdo above-the-fold (usar RSC)
INP (Interaction to Next Paint):
□ Mover lógica pesada para Server Components
□ useTransition para updates não-urgentes
□ Dynamic import para componentes pesados
□ Evitar hydration mismatch (causa re-renders completos)
CLS (Cumulative Layout Shift):
□ width + height em todas as imagens
□ Skeleton loaders com dimensões fixas
□ Evitar inject de conteúdo acima do fold após load
□ Font display swap com tamanhos consistentes (next/font)
TTFB (Time to First Byte):
□ ISR/SSG para rotas que podem ser estáticas
□ Streaming com Suspense para rotas dinâmicas
□ Edge Runtime para middleware leve
□ CDN na frente (Vercel, Cloudflare, etc.)
8.6 Configuracoes Avancadas do next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// Output mode
output: 'standalone', // Para Docker/self-hosting
// Imagens de domínios externos
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**.example.com', // Wildcard para subdomínios
},
],
},
// Headers customizados
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: 'https://meusite.com' },
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' },
],
},
]
},
// Redirects (processados antes de rotas)
async redirects() {
return [
{
source: '/blog/:slug',
destination: '/posts/:slug',
permanent: true, // 308 (SEO: transfere ranking)
},
]
},
// Rewrites (proxy transparente)
async rewrites() {
return [
{
source: '/api/v1/:path*',
destination: 'https://api-legacy.example.com/:path*',
},
]
},
// Otimização de imports (tree-shaking em barrel files)
experimental: {
optimizePackageImports: ['lucide-react', '@headlessui/react'],
},
}
export default nextConfig
Mapa Mental: Decisoes Arquiteturais
Preciso de SEO?
├── Não → SPA (Vite + React) pode ser suficiente
└── Sim → Next.js
│
Conteúdo muda com frequência?
├── Não (ou raramente) → SSG + ISR
│ Exemplo: blog, docs, e-commerce catalog
│
├── A cada request → SSR
│ Exemplo: dashboard personalizado, feed social
│
└── Parte estática + parte dinâmica → PPR
Exemplo: página de produto (info estática + reviews dinâmicas)
Preciso de API?
├── Mutations simples (CRUD) → Server Actions
├── API pública / webhooks → Route Handlers
└── API complexa com muitos consumers → Backend separado (NestJS, Go, etc.)
Onde deployar?
├── Mínimo esforço → Vercel (zero-config, ISR nativo)
├── Controle total → Docker + standalone output
└── Edge performance → Cloudflare Workers (com adapter)
Erros Comuns e Como Evitar
1. "Hydration mismatch" — HTML do servidor ≠ HTML do client
Causa: usar Date.now(), Math.random(), ou window em render
Fix: useEffect para valores client-only, ou suppressHydrationWarning
2. "Cannot use useState in a Server Component"
Causa: tentou usar hooks em componente sem 'use client'
Fix: extrair a parte interativa para um Client Component separado
3. Cache servindo dados stale e você não sabe por quê
Causa: Data Cache + Full Route Cache + Router Cache combinados
Fix: entender qual camada está cacheando e usar revalidate/no-store
4. Server Action não atualiza a UI
Causa: esqueceu de chamar revalidatePath/revalidateTag após mutação
Fix: sempre revalidar o cache relevante no final da action
5. Middleware pesado aumentando latência
Causa: operações complexas no Edge Runtime (ex: consultas a banco)
Fix: middleware deve ser leve — validar JWT, redirect, headers apenas
6. Bundle size não diminuiu com App Router
Causa: 'use client' no topo de componentes que poderiam ser Server
Fix: empurrar 'use client' para as folhas da árvore de componentes
7. "Dynamic server usage" em rota que deveria ser estática
Causa: cookies(), headers() ou searchParams usados na rota
Fix: mover a lógica dinâmica para dentro de Suspense (PPR) ou aceitar SSR
Referencias e Fontes
- Next.js Official Documentation — https://nextjs.org/docs — Documentacao oficial cobrindo App Router, Server Components, data fetching, caching e todas as APIs do framework
- Vercel Blog — https://vercel.com/blog — Artigos tecnicos da equipe por tras do Next.js sobre arquitetura, performance, edge computing e novas features do framework
- “Next.js in Action” — Guia pratico e aprofundado sobre desenvolvimento de aplicacoes com Next.js, incluindo padroes de arquitetura, deploy e otimizacao