Destino Dev

Blog

Implementando ISR em Nuxt 3 (Nitro): gerar, invalidar e cache por rota

Por John Pires · 13 de março de 2026

Implementando ISR em Nuxt 3 (Nitro): gerar, invalidar e cache por rota

Contexto e motivacao

Por que ISR em Nuxt 3?

Incremental Static Regeneration (ISR) permite servir conteúdo estático com a capacidade de atualizá-lo sob demanda ou periodicamente sem precisar rebuildar toda a aplicação. Em Nuxt 3, que roda sobre Nitro, não existe um método "oficial" idêntico ao do Next.js, mas você pode implementar um circuito de ISR controlado usando server routes, cache persistente e background revalidation.

Quando usar ISR em Nuxt 3

Use ISR quando você tem páginas que são majoritariamente estáticas, mas que precisam refletir atualizações do conteúdo com latência controlada — por exemplo, posts de blog, páginas de produto ou páginas de marketing. ISR reduz carga de origem e tempo de build enquanto mantém conteúdo relativamente fresco.

Este artigo foca em um padrão prático: cache por rota de dados (JSON), revalidação em background (stale-while-revalidate) e endpoint seguro de invalidação. A escolha por cache de API torna o approach compatível com SSR e SSG no Nuxt, mantendo renderização server-side simples.

Como funciona por baixo dos panos

Arquitetura resumida

Resumo do fluxo que vamos implementar:

  • Rota API /api/posts/[slug] retorna JSON de conteúdo.
  • A rota mantém um cache em disco (ou /tmp) com metadata de expiração.
  • Se o cache estiver válido, retorna imediatamente.
  • Se expirado, retorna o conteúdo stale imediatamente, dispara uma regeneração em background e atualiza o cache.
  • Há um endpoint /api/revalidate para invalidação manual com secret.

Decisões técnicas e trade-offs

Optamos por cache de dados e não HTML para simplicidade: renderizar HTML programaticamente fora do pipeline Nuxt pode complicar contexto SSR, hooks e plugins. Cache de JSON é suficiente para a maioria dos casos e reduz pontos de falha.

Armazenamento em disco é simples e portátil. Para escala, substitua por Redis, S3 ou outro storage que exponha get/set com TTL. O padrão do código é genérico: o adaptador de armazenamento pode ser trocado sem alterar consumer da rota.

Implementacao passo a passo

1) Estrutura de arquivos

Crie estas rotas dentro da pasta /server/api do seu projeto Nuxt 3:

  • /server/api/posts/[slug].ts — API ISR por rota
  • /server/api/revalidate.ts — endpoint para invalidar/revalidar cache
  • /composables/usePost.ts — composable cliente para consumir a API

2) Endpoint ISR: /server/api/posts/[slug].ts

O handler abaixo implementa o padrão stale-while-revalidate com cache em disco. Antes do bloco de código eu explico o que faz: ele tenta ler um arquivo JSON de cache com shape { meta: { expiresAt }, data }. Se válido, retorna. Se expirado, retorna conteúdo stale e dispara regeneration async.

import { promises as fs } from 'fs'
import { resolve } from 'path'
import { H3Event } from 'h3'

const CACHE_DIR = process.env.POST_CACHE_DIR || './.nitro/post-cache'
const TTL_MS = 1000 * 60 * 5 // 5 minutos de validade

async function fetchFromOrigin(slug: string) {
  // Substitua esta função por fetch real de CMS/DB
  const res = await fetch(`https://example.com/content/${slug}`)
  if (!res.ok) throw new Error('origin fetch failed')
  return res.json()
}

export default defineEventHandler(async (event: H3Event) => {
  const slug = event.context.params?.slug
n  if (!slug) return sendError(event, createError({ statusCode: 400, statusMessage: 'missing slug' }))

  const filePath = resolve(CACHE_DIR, `${slug}.json`)

  // try read cache
  try {
    const raw = await fs.readFile(filePath, 'utf-8')
    const parsed = JSON.parse(raw)
    const expiresAt = parsed.meta?.expiresAt || 0

    if (Date.now() < expiresAt) {
      // cache valido
      return { source: 'cache', data: parsed.data }
    }

    // cache expirado -> revalidar em background e retornar stale
    revalidateInBackground(slug, filePath).catch(err => console.error('reval err', err))
    return { source: 'stale-cache', data: parsed.data }

  } catch (err: any) {
    // cache nao existe ou leitura falhou -> gerar synchronously
    try {
      const data = await fetchFromOrigin(slug)
      const meta = { expiresAt: Date.now() + TTL_MS }
      await fs.mkdir(CACHE_DIR, { recursive: true })
      await fs.writeFile(filePath, JSON.stringify({ meta, data }), 'utf-8')
      return { source: 'origin', data }
    } catch (e) {
      return sendError(event, createError({ statusCode: 502, statusMessage: 'failed to fetch origin' }))
    }
  }
})

async function revalidateInBackground(slug: string, filePath: string) {
  const data = await fetchFromOrigin(slug)
  const meta = { expiresAt: Date.now() + TTL_MS }
  await fs.writeFile(filePath, JSON.stringify({ meta, data }), 'utf-8')
}

O que acontece depois: a primeira requisição sem cache faz fetch síncrono e popula o cache. Depois da expiração, a rota devolve o stale rapidamente e inicia um fetch em background que atualiza o cache para próximas requisições.

3) Endpoint de revalidação manual

Um endpoint simples para invalidar o cache e, opcionalmente, forçar regeneração. Use uma chave secreta para limitar acesso.

import { promises as fs } from 'fs'
import { resolve } from 'path'

const CACHE_DIR = process.env.POST_CACHE_DIR || './.nitro/post-cache'
const REVALIDATE_SECRET = process.env.REVALIDATE_SECRET || 'changeme'

export default defineEventHandler(async (event) => {
  if (event.node.req.method !== 'POST') return sendError(event, createError({ statusCode: 405 }))
  const body = await readBody(event)
  if (body?.secret !== REVALIDATE_SECRET) return sendError(event, createError({ statusCode: 401 }))

  const slug = body.slug
  if (!slug) return sendError(event, createError({ statusCode: 400, statusMessage: 'slug required' }))

  const filePath = resolve(CACHE_DIR, `${slug}.json`)
  try {
    await fs.unlink(filePath)
  } catch (e) {
    // ignore if not exists
  }

  // opcional: disparar reval imediata (fire-and-forget)
  event.node.res.statusCode = 200
  return { revalidated: true, slug }
})

Depois de invalidar, a próxima chamada ao endpoint de posts fará fetch origin e repovoará o cache.

4) Consumindo a API do lado do servidor na página

Consumo dentro de pages/posts/[slug].vue usando useAsyncData no server side para manter SSR e fallback de SEO.

<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const slug = route.params.slug as string

const { data: post, pending, error } = await useAsyncData(
  `post-${slug}`,
  () => $fetch(`/api/posts/${slug}`)
)
</script>

<template>
  <div v-if="error">Erro: {{ error.message }}</div>
  <article v-else>
    <h1>{{ post.data.title }}</h1>
    <div v-html="post.data.body" />
  </article>
</template>

O que acontece: a página é SSR com os dados retornados pelo endpoint. Se o cache estiver stale, o HTML ainda terá o conteúdo antigo, enquanto o cache é refrescado em background.

Patterns e boas praticas

Segurança e invalidação

Use sempre um secret para endpoints de invalidação. Regeneração automática é útil, mas a invalidação manual é necessária para integrações com CMS webhooks.

Recomendo que o webhook do CMS invoque /api/revalidate com o slug alterado e o secret. Assim as mudanças aparecem quase imediatamente sem expor endpoints públicos.

TTL, stale-while-revalidate e SLAs

Escolha o TTL conforme a tolerância a staleness do seu produto. Para notícias, pode ser 30s; para marketing, 5-10 minutos. Combine TTL com monitoramento de tempo de revalidação para garantir SLAs.

Se revalidação falhar repetidamente, exponha métricas (Prometheus/Grafana) ou logs que permitam identificar problemas no fetch da origem.

Swappable storage (adapter pattern)

O exemplo usa disco. Extraia um adaptador simples com métodos get/set/delete para trocar por Redis ou S3. Exemplo de interface:


interface CacheAdapter {
  get(key: string): Promise<{meta: {expiresAt:number}, data:any}|null>
  set(key: string, payload: any, ttlMs: number): Promise<void>
  del(key: string): Promise<void>
}

Ao isolar essa camada, migrar para infra gerenciada é trivial.

Armadilhas comuns e como evitar

1) Não bloquear request enquanto revalida

Erros comuns: revalidar síncronamente no request path — isso causa latência alta na primeira requisição pós-expiração. Use fire-and-forget ou filas.

Tip: no Node.js você pode disparar promise sem await. Em ambientes serverless, garanta que a execução em background não seja cortada — prefira filas/worker ou invalidação seguida de rebuild por worker.

2) Corrida de escrita no cache

Se muitas requisições chegam simultâneas para a mesma rota sem cache, múltiplas regenerações podem ocorrer. Estratégias para evitar:

  • Locking leve: write-ahead file lock ou lock em Redis (SETNX).
  • Deduplicação: checar e esperar um curto período antes de disparar nova fetch.

Exemplo rápido de dedupe (pseudo): ao perceber cache ausente, tente criar um arquivo .lock; desaparecido, aguarde até 200ms para ver se outro processo já criou cache.

3) Falha de revalidação contínua

Se a fetch para origem falhar, não sobrescreva o cache com erro. Mantenha o stale e registre incidente. Circuit breaker pode bloquear novas tentativas por um período crescente.

4) Tamanho do cache e armazenamento

Caches em disco podem crescer indefinidamente. Adicione políticas de expurgo e tamanho máximo. Para Redis, use TTL nativo. Para S3, considere Lifecycle rules.

Também controlamos o conteúdo a cachear: guarde apenas o necessário (JSON minimal), evite armazenar binários ou imagens — mantenha imagens em CDN.

Armadilhas de infraestrutura e monitoramento

Observabilidade

Registre métricas básicas: cache hit ratio, reval success/fail, latency da origem. Sem isso, será difícil entender o comportamento em produção.

Erros de permissões e paths

Se usar disco, em ambientes serverless (Vercel, Netlify) o sistema de arquivos pode não ser persistente. Para esses provedores, troque para Redis ou um bucket. Em containers, monte volume persistente.

Teste local e CI para garantir permissão de escrita e criação de diretórios antes de lançar.

Conclusao pratica

Implementar ISR em Nuxt 3 com Nitro é uma questão de padrão: cache de API por rota, revalidação em background e endpoints de invalidação seguros. O exemplo mostrado é operacional, portável e fácil de estender para Redis/S3 quando necessário.

Checklist rápido para levar a produção:

  1. Extrair adaptador de storage e trocar para Redis/S3 conforme ambiente.
  2. Adicionar locking/deduplicação para evitar thundering herd.
  3. Expor endpoint seguro de revalidate e integrar com webhooks do CMS.
  4. Adicionar métricas e alertas para falhas na revalidação.

Com esses passos você terá um ISR seguro e previsível em Nuxt 3, mantendo builds rápidos e conteúdo fresco sem sacrificar performance.

Próximo passo prático: extraia a lógica de cache para um composable/server util e implemente um adapter Redis para ambientes serverless. Comece substituindo fs por um adapter que respeite a mesma interface e valide localmente.

Se quiser, posso gerar uma versão do adaptador para Redis + exemplo de bloqueio SETNX e integração com webhook do Contentful/Ghost/Strapi.

Assuntos: Nuxt.js, nitro, isr, caching, server-routes

Leia também