Next.js App Router: Server Components, Streaming e Estratégias de Cache
React Server Components vs Client Components, fetch com cache e revalidação (force-cache, no-store, revalidate), Suspense para streaming progressivo, generateStaticParams para SSG e quando usar cada estratégia.
O App Router do Next.js introduziu uma mudança arquitetural profunda: por padrão, todos os componentes são React Server Components (RSC). Eles rodam exclusivamente no servidor — sem enviar JavaScript para o cliente, sem hidratação, com acesso direto a bancos de dados e secrets. Apenas os componentes que precisam de interatividade (useState, useEffect, event handlers) recebem a diretiva 'use client'.
Essa distinção impacta performance, SEO e segurança de forma fundamental. Este artigo cobre as estratégias de renderização e cache do App Router — leia o arquivo em node_modules/next/dist/docs/ para as convenções atuais antes de escrever código.
Server Components vs Client Components
- Server Component (padrão) — roda no servidor em tempo de requisição ou build. Pode ser
async, acessar banco de dados direto, usar secrets, ler o filesystem. Não pode usar hooks, event handlers ou APIs de browser. - Client Component (`'use client'`) — hidratado no browser. Pode usar hooks e event handlers. Recebe dados via props do Server Component pai. Bundle JavaScript enviado ao cliente.
- Regra de composição: Server Components podem importar Client Components. Client Components não podem importar Server Components (apenas receber como children via props).
O Server Component assíncrono quebra um paradigma: antes, toda página precisava de getServerSideProps ou getStaticProps separados do componente. Com RSC, o próprio componente é async e faz a busca de dados diretamente — sem funções auxiliares, sem API route intermediária. O Suspense com fallback é o que permite o streaming progressivo: o HTML do shell da página é enviado imediatamente, e as partes dentro do Suspense são transmitidas para o browser conforme ficam prontas no servidor.
import { Suspense } from 'react';
import { ProductList } from './ProductList';
import { ProductListSkeleton } from './ProductListSkeleton';
import { db } from '../../lib/db';
// generateMetadata: SEO dinâmico — roda no servidor
export async function generateMetadata() {
return {
title: 'Produtos | Loja',
description: 'Explore nossa linha completa de produtos.',
};
}
// Page: Server Component assíncrono
export default async function ProductsPage({
searchParams,
}: {
searchParams: { page?: string; category?: string };
}) {
// Acesso direto ao banco — sem API route intermediária
// A query roda no servidor — o cliente nunca vê a connection string
const categories = await db.query.categories.findMany({
orderBy: (c, { asc }) => asc(c.name),
});
return (
<main>
<h1>Nossos Produtos</h1>
<CategoryFilter categories={categories} /> {/* Client Component */}
{/* Suspense: streaming progressivo
A página carrega instantaneamente com o skeleton
enquanto a lista de produtos (potencialmente mais lenta) ainda carrega */}
<Suspense fallback={<ProductListSkeleton />}>
<ProductList
page={Number(searchParams.page ?? 1)}
category={searchParams.category}
/>
</Suspense>
</main>
);
}O fetch dentro de Server Components tem cache integrado com semântica configurável por chamada. Isso é radicalmente diferente do comportamento padrão do fetch no browser: no App Router, cada chamada fetch decide sua própria estratégia de cache — o mesmo componente pode ter uma parte dos dados com cache de 1 hora e outra parte sem cache algum.
interface ProductListProps {
page: number;
category?: string;
}
async function getProducts(page: number, category?: string) {
// fetch no App Router tem cache integrado
const res = await fetch(
`${process.env.API_URL}/products?page=${page}${category ? `&category=${category}` : ''}`,
{
// Estratégias de cache:
// force-cache (padrão): reutiliza o cache indefinidamente
// Igual ao SSG — use para dados que raramente mudam
// next: { cache: 'force-cache' }
// no-store: sem cache — busca sempre frescos
// Use para dados altamente dinâmicos (estoque em tempo real)
// cache: 'no-store'
// revalidate: cache por tempo (ISR — Incremental Static Regeneration)
// Reutiliza cache por N segundos, depois regenera em background
next: { revalidate: 60 }, // Cache por 60 segundos
}
);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json() as Promise<{ data: Array<Product>; meta: PaginationMeta }>;
}
export async function ProductList({ page, category }: ProductListProps) {
const { data: products, meta } = await getProducts(page, category);
return (
<div>
<ul className="grid grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</ul>
<Pagination meta={meta} /> {/* Client Component para interatividade */}
</div>
);
}O generateStaticParams é o equivalente moderno do getStaticPaths: ele instrui o Next.js a gerar páginas estáticas durante o build para as rotas mais acessadas. Páginas geradas estaticamente têm latência de zero do servidor — são servidas diretamente da CDN. O dynamicParams = true (padrão) garante que rotas não pré-geradas sejam renderizadas sob demanda (ISR) em vez de retornar 404.
// generateStaticParams: gera páginas estáticas em build time
// para as rotas mais acessadas — zero latência de servidor
export async function generateStaticParams() {
const products = await fetch(`${process.env.API_URL}/products?limit=100`)
.then((r) => r.json());
// Gera páginas estáticas para os 100 produtos mais populares
return products.data.map((product: Product) => ({
slug: product.slug,
}));
}
// Produtos não listados em generateStaticParams são gerados
// sob demanda (ISR) ou retornam 404
export const dynamicParams = true; // Permite geração on-demand
export default async function ProductDetailPage({
params,
}: {
params: { slug: string };
}) {
const product = await fetch(
`${process.env.API_URL}/products/${params.slug}`,
{ next: { revalidate: 300 } } // Revalida a cada 5 minutos
).then((r) => {
if (!r.ok) throw new Error('Not found');
return r.json();
});
return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
</main>
);
}'use client';
import { useState } from 'react';
interface CategoryFilterProps {
categories: Array<Category>;
}
// Client Component: precisa de estado local e URL
export function CategoryFilter({ categories }: CategoryFilterProps) {
const [selected, setSelected] = useState<string | null>(null);
const router = useRouter();
const searchParams = useSearchParams();
function handleSelect(categoryId: string) {
setSelected(categoryId);
const params = new URLSearchParams(searchParams.toString());
params.set('category', categoryId);
router.push(`?${params.toString()}`);
}
return (
<div className="flex gap-2">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => handleSelect(cat.id)}
className={selected === cat.id ? 'bg-indigo-600 text-white' : 'bg-gray-100'}
>
{cat.name}
</button>
))}
</div>
);
}Quando usar cada estratégia: force-cache para landing pages, páginas de documentação e conteúdo editorial que muda raramente. revalidate: N (ISR) para listagens de produtos, preços — dados que mudam às vezes. no-store para dados em tempo real (estoque, cotações). 'use client' apenas para componentes que precisam de interatividade — não para toda a página.
Conclusão
O App Router do Next.js muda o modelo mental de 'página que busca dados' para 'componentes que são dados'. Server Components trazem dados do banco direto para a renderização sem JavaScript extra no cliente. O Suspense com streaming entrega o HTML principal instantaneamente enquanto partes mais lentas carregam em paralelo. A estratégia de cache por componente (force-cache, revalidate, no-store) dá controle granular entre performance e atualidade dos dados.