Caching no Next.js: ISR, On-Demand e Stale-While-Revalidate

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O problema que ninguém quer resolver manualmente
Cache é a diferença entre um site que responde em 50ms e um que leva 3 segundos. No Next.js, essa diferença se traduz em três estratégias principais: ISR, revalidação on-demand e stale-while-revalidate.
Cada uma resolve um problema diferente. Escolher errado custa performance ou dados desatualizados.
Vamos destrinchar cada estratégia com código real.
Antes de tudo: o modelo mental
O Next.js opera com camadas de cache. Entender a hierarquia é essencial.
Requisição do usuário
→ Edge/CDN Cache (se houver)
→ Full Route Cache (servidor Next.js)
→ Data Cache (fetch cache interno)
→ Origem (banco, API, CMS)Cada estratégia que vamos ver atua em camadas diferentes desse fluxo. ISR atua no Full Route Cache. O stale-while-revalidate atua no Data Cache. A revalidação on-demand invalida ambos.
Guarde esse modelo. Ele vai clarear tudo.
ISR: Incremental Static Regeneration
ISR é a ideia de gerar páginas estáticas sob demanda e regenerá-las em intervalos. Você define um tempo de revalidação. Quando esse tempo expira, a próxima requisição dispara uma regeneração em background.
O usuário que disparou a regeneração ainda recebe a versão antiga. O próximo visitante já recebe a nova.
ISR no App Router
No App Router (Next.js 13+), ISR é controlado pela opção revalidate no fetch ou no segmento de rota.
// app/blog/[slug]/page.tsx
interface Post {
id: string;
slug: string;
title: string;
content: string;
updatedAt: string;
}
async function getPost(slug: string): Promise<Post> {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 60 }, // regenera a cada 60 segundos
});
if (!res.ok) throw new Error("Post não encontrado");
return res.json();
}
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>
<time>{new Date(post.updatedAt).toLocaleDateString("pt-BR")}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}O revalidate: 60 diz ao Next.js: "sirva a versão em cache por 60 segundos. Depois disso, regenere em background."
ISR no Pages Router (legado)
Se você ainda usa Pages Router, a configuração é via getStaticProps:
// pages/blog/[slug].tsx
import { GetStaticProps, GetStaticPaths } from "next";
export const getStaticPaths: GetStaticPaths = async () => {
const res = await fetch("https://api.example.com/posts");
const posts: { slug: string }[] = await res.json();
return {
paths: posts.map((post) => ({ params: { slug: post.slug } })),
fallback: "blocking",
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const res = await fetch(`https://api.example.com/posts/${params?.slug}`);
if (!res.ok) return { notFound: true };
return {
props: { post: await res.json() },
revalidate: 60,
};
};O fallback: "blocking" garante que slugs novos sejam gerados na primeira requisição. Sem 404 falso.
Quando usar ISR puro
ISR com timer funciona bem para conteúdo que muda com frequência previsível. Exemplos:
- Páginas de produto com estoque atualizado a cada minuto.
- Dashboards públicos com métricas atualizadas a cada 5 minutos.
- Feeds de blog onde 60 segundos de atraso são aceitáveis.
O problema? Você não controla quando a invalidação acontece. Se o conteúdo muda 1 segundo depois da regeneração, o usuário vê dados velhos por quase 60 segundos.
É aqui que entra a revalidação on-demand.
Revalidação On-Demand: controle cirúrgico
Em vez de esperar um timer, você invalida o cache quando o dado muda. O CMS publicou um post? Dispare uma revalidação. O admin atualizou o preço? Invalide a página do produto.
Configurando a Route Handler
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
const { secret, path, tag } = body;
// Validação do secret para evitar invalidações maliciosas
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: "Token inválido" }, { status: 401 });
}
try {
if (tag) {
revalidateTag(tag);
return NextResponse.json({ revalidated: true, tag });
}
if (path) {
revalidatePath(path);
return NextResponse.json({ revalidated: true, path });
}
return NextResponse.json(
{ message: "Informe path ou tag" },
{ status: 400 }
);
} catch (error) {
return NextResponse.json(
{ message: "Erro na revalidação" },
{ status: 500 }
);
}
}Usando tags para invalidação granular
Tags são o mecanismo mais poderoso. Você marca fetches com tags e invalida por tag.
// app/blog/[slug]/page.tsx
async function getPost(slug: string): Promise<Post> {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: {
tags: [`post-${slug}`, "posts"],
revalidate: 3600, // fallback: 1 hora se o webhook falhar
},
});
if (!res.ok) throw new Error("Post não encontrado");
return res.json();
}
async function getRelatedPosts(slug: string): Promise<Post[]> {
const res = await fetch(
`https://api.example.com/posts/${slug}/related`,
{
next: {
tags: [`post-${slug}-related`, "posts"],
revalidate: 3600,
},
}
);
return res.json();
}Agora, quando o CMS publica uma alteração:
# Invalida apenas um post específico
curl -X POST https://seusite.com/api/revalidate \
-H "Content-Type: application/json" \
-d '{"secret":"MEU_SECRET","tag":"post-meu-artigo"}'
# Invalida todos os posts (listagens, feeds, etc.)
curl -X POST https://seusite.com/api/revalidate \
-H "Content-Type: application/json" \
-d '{"secret":"MEU_SECRET","tag":"posts"}'Webhook do CMS na prática
A maioria dos CMS headless suporta webhooks. Aqui está um handler para o Sanity:
// app/api/revalidate/sanity/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
import { parseBody } from "next-sanity/webhook";
export async function POST(request: NextRequest) {
try {
const { isValidSignature, body } = await parseBody<{
_type: string;
slug?: { current: string };
}>(request, process.env.SANITY_WEBHOOK_SECRET!);
if (!isValidSignature) {
return NextResponse.json({ message: "Assinatura inválida" }, { status: 401 });
}
if (!body?._type) {
return NextResponse.json({ message: "Tipo ausente" }, { status: 400 });
}
// Invalidação cirúrgica por tipo de conteúdo
switch (body._type) {
case "post":
revalidateTag("posts");
if (body.slug?.current) {
revalidateTag(`post-${body.slug.current}`);
}
break;
case "author":
revalidateTag("authors");
break;
default:
revalidateTag(body._type);
}
return NextResponse.json({ revalidated: true });
} catch (error) {
return NextResponse.json({ message: "Erro interno" }, { status: 500 });
}
}Instale a dependência:
npm install next-sanityrevalidatePath vs revalidateTag
Diferença crucial:
revalidatePath("/blog"): invalida a rota inteira. Todos os dados daquela página são refetchados.revalidateTag("posts"): invalida apenas os fetches marcados com aquela tag. Mais granular.
Use revalidateTag sempre que possível. É mais eficiente e previsível.
Stale-While-Revalidate: o padrão HTTP que o Next.js abraçou
SWR não é apenas a biblioteca useSWR. É um padrão HTTP definido na RFC 5861. A ideia é simples: sirva o dado "velho" imediatamente enquanto busca o novo em background.
O ISR do Next.js já implementa esse padrão no servidor. Mas você pode controlá-lo explicitamente.
SWR no Data Cache do Next.js
Quando você faz revalidate: 60, o comportamento real é:
- 0-60s: serve do cache. Nenhum fetch.
- 60s+: serve do cache (stale) E dispara fetch em background.
- Próxima requisição: serve o dado novo.
Esse é exatamente o padrão stale-while-revalidate.
Headers customizados para CDN
Se você usa Vercel, Cloudflare ou outra CDN, pode controlar o SWR via headers:
// app/api/products/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const products = await fetchProducts();
return NextResponse.json(products, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
},
});
}Esse header diz à CDN:
s-maxage=60: cache válido por 60 segundos.stale-while-revalidate=300: por mais 300 segundos, sirva stale enquanto revalida.
Total: 6 minutos de "proteção". Nos primeiros 60s, cache fresco. Dos 60s aos 360s, cache stale com revalidação em background. Depois de 360s, cache miss completo.
SWR no cliente com useSWR
Para dados que mudam com interação do usuário, o SWR no cliente é imbatível:
npm install swr// hooks/use-products.ts
import useSWR from "swr";
interface Product {
id: string;
name: string;
price: number;
stock: number;
}
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export function useProducts(category: string) {
const { data, error, isLoading, mutate } = useSWR<Product[]>(
`/api/products?category=${category}`,
fetcher,
{
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 5000, // evita fetches duplicados em 5s
refreshInterval: 30000, // polling a cada 30s
}
);
return {
products: data ?? [],
isLoading,
isError: !!error,
refresh: mutate,
};
}// components/product-list.tsx
"use client";
import { useProducts } from "@/hooks/use-products";
export function ProductList({ category }: { category: string }) {
const { products, isLoading, refresh } = useProducts(category);
if (isLoading) return <p>Carregando...</p>;
return (
<div>
<button onClick={() => refresh()}>Atualizar</button>
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} — R$ {product.price} ({product.stock} em estoque)
</li>
))}
</ul>
</div>
);
}O mutate() é poderoso. Você pode fazer optimistic updates:
async function updatePrice(productId: string, newPrice: number) {
// Atualiza o cache local imediatamente
mutate(
(current) =>
current?.map((p) =>
p.id === productId ? { ...p, price: newPrice } : p
),
{ revalidate: false }
);
// Envia para o servidor
await fetch(`/api/products/${productId}`, {
method: "PATCH",
body: JSON.stringify({ price: newPrice }),
});
// Revalida com o dado real do servidor
mutate();
}UI instantânea. Sem loading. Sem flicker.
Combinando estratégias: a arquitetura real
Na prática, você não usa apenas uma estratégia. Veja um cenário de e-commerce:
// app/products/[slug]/page.tsx
// ISR com timer como fallback + tags para invalidação on-demand
async function getProduct(slug: string) {
return fetch(`${process.env.API_URL}/products/${slug}`, {
next: {
tags: [`product-${slug}`, "products"],
revalidate: 300, // fallback: 5 minutos
},
}).then((res) => res.json());
}
// Dados de estoque: sem cache (sempre frescos)
async function getStock(productId: string) {
return fetch(`${process.env.API_URL}/stock/${productId}`, {
cache: "no-store", // nunca cacheia
}).then((res) => res.json());
}
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const product = await getProduct(slug);
const stock = await getStock(product.id);
return (
<main>
<h1>{product.name}</h1>
<p>R$ {product.price}</p>
{/* Estoque atualizado em tempo real no cliente */}
<StockDisplay productId={product.id} initial={stock.quantity} />
</main>
);
}// components/stock-display.tsx
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function StockDisplay({
productId,
initial,
}: {
productId: string;
initial: number;
}) {
const { data } = useSWR(`/api/stock/${productId}`, fetcher, {
refreshInterval: 10000,
fallbackData: { quantity: initial },
});
return (
<span className={data.quantity > 0 ? "text-green-600" : "text-red-600"}>
{data.quantity > 0 ? `${data.quantity} em estoque` : "Esgotado"}
</span>
);
}Três camadas trabalhando juntas:
- ISR + on-demand para dados do produto (nome, preço, descrição).
no-storeno servidor para estoque inicial.- SWR no cliente para estoque em tempo real com polling.
Debugging: como saber se o cache está funcionando
Adicione headers de debug no next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
logging: {
fetches: {
fullUrl: true,
},
},
};
export default nextConfig;No terminal do next dev, você verá:
GET https://api.example.com/posts/meu-artigo 200 in 45ms (cache: HIT)
GET https://api.example.com/stock/123 200 in 120ms (cache: SKIP)Em produção na Vercel, use o header x-vercel-cache:
- HIT: servido do cache.
- STALE: servido stale, revalidando em background.
- MISS: cache miss, buscou da origem.
Armadilhas comuns
1. Cache que não invalida
Se você usa fetch com revalidateTag mas o dado não atualiza, verifique se a URL do fetch é exatamente a mesma. O Next.js usa a URL como chave de cache.
2. revalidate conflitante no layout
// app/layout.tsx
// CUIDADO: isso força revalidate: 0 em TODAS as páginas filhas
export const revalidate = 0;O revalidate mais restritivo vence. Se o layout tem revalidate: 0, todas as páginas filhas perdem o cache.
3. Fetch sem opções de cache
// Isso usa o padrão do Next.js: cache automático com heurística
const res = await fetch("https://api.example.com/data");
// Seja explícito. Sempre.
const res = await fetch("https://api.example.com/data", {
next: { revalidate: 60 },
});Seja explícito. Defaults implícitos causam bugs difíceis de rastrear.
Tabela de decisão rápida
| Cenário | Estratégia | Config |
|---|---|---|
| Blog / marketing pages | ISR + on-demand | revalidate: 3600 + webhook com revalidateTag |
| Preços de produto | On-demand | revalidateTag via webhook do admin |
| Estoque em tempo real | no-store + SWR client | cache: "no-store" + useSWR com polling |
| Dashboard público | ISR com timer curto | revalidate: 30 |
| Dados do usuário logado | Sem cache no servidor | cache: "no-store" ou Route Handler dinâmico |
Minha Opinião Sincera
ISR com revalidação on-demand é a melhor estratégia para 90% dos sites. O timer do ISR sozinho é uma muleta. Ele foi a solução possível antes do Next.js 12.1 introduzir on-demand revalidation, mas hoje não faz sentido depender só dele.
O timer deve ser fallback, não estratégia principal. Configure revalidate: 3600 como rede de segurança e use tags + webhooks para invalidação real. Se o webhook falhar, o timer garante que o dado não fica velho para sempre.
Sobre useSWR no cliente: é excelente, mas vejo gente abusando. Se o dado não muda com interação do usuário, ele não precisa de SWR no cliente. Busque no servidor com ISR e pronto. Cada useSWR é um fetch a mais que o browser do usuário faz. Em mobile com 3G, isso importa.
O que me frustra no Next.js é a complexidade acidental do cache. O App Router tem quatro camadas de cache (Request Memoization, Data Cache, Full Route Cache e Router Cache). Entender quando cada uma atua exige ler a documentação três vezes. A equipe do Next.js reconheceu isso e simplificou o Router Cache no Next.js 15, mas ainda é mais complexo do que deveria.
Alternativas como Astro resolvem isso de forma mais simples: tudo é estático por padrão, e você opta por dinamismo quando precisa. Menos mágica, menos surpresas. Mas o Astro não tem a mesma flexibilidade para apps interativos.
Se você está começando um projeto hoje, minha recomendação é: ISR com tags + on-demand como padrão. no-store para dados de usuário autenticado. SWR no cliente apenas para dados que mudam em tempo real na tela. Essa combinação cobre praticamente qualquer cenário sem overengineering.
Cache bem feito é invisível. O usuário não sabe que existe. Ele só sabe que o site é rápido.
Quando NAO usar ISR: os 3 cenarios que quebram cache
ISR e poderoso, mas nao e bala de prata. Ja vi equipes perderem semanas debugando problemas que nao existiriam se tivessem escolhido a estrategia certa desde o inicio.
1. Dados personalizados por usuario
Dashboard com informacoes do perfil, historico de compras, notificacoes. Se voce colocar ISR numa pagina que mostra dados especificos do usuario, todos os visitantes vao ver os mesmos dados: os do primeiro usuario que gerou o cache.
O que fazer: Use dynamic = 'force-dynamic' ou busque dados no cliente com SWR/React Query. Pagina shell estática + dados dinamicos no client side.
2. Dados que mudam em segundos
Cotacoes de bolsa, placar ao vivo, chat em tempo real. ISR com revalidate: 60 significa que o usuario ve dados de ate 1 minuto atras. Para muitos casos, isso e inaceitavel.
O que fazer: WebSocket ou Server-Sent Events para dados real-time. ISR so funciona para dados que mudam em minutos ou horas, nao segundos.
3. Formularios e acoes de escrita
Checkout, edicao de perfil, envio de mensagens. Qualquer pagina onde o usuario escreve dados nao deve ser cacheada. Um POST que retorna uma pagina ISR pode mostrar o estado antigo apos o submit.
O que fazer: Server Actions com revalidatePath() ou revalidateTag() para invalidar cache apos mutacoes. Nunca confie que o cache reflete o estado mais recente apos um write.
Regra pratica: Se a resposta depende de quem esta perguntando (autenticacao, personalizacao) ou muda mais rapido que o revalidate, ISR e a ferramenta errada.
## Leia tambem
- [Server Components no Next.js 16](/nextjs/server-components-nextjs-16-guia-completo)
- [CI/CD para Next.js na Vercel](/nextjs/cicd-nextjs-vercel-github-actions)
- [Turbopack](/nextjs/tutorial-avancado-nextjs-turbopack)
Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


