Destino Dev

Blog

Nuxt: criar um módulo que auto-importa composables e server routes com cache SSR

Por John Pires · 13 de março de 2026

Nuxt: criar um módulo que auto-importa composables e server routes com cache SSR

1) Problema real que resolve

Em projetos Nuxt de médio porte os desenvolvedores frequentemente repetem duas tarefas: registrar composables para auto-import e mapear rotas de servidor (server routes / api) para o bundle. Quando a aplicação cresce, a falta de padronização gera duplicação, imports manuais e risco de esquecer rotas em ambientes SSR. Além disso, muitos endpoints server-side consultam APIs externas ou banco de dados e podem se beneficiar de caching em tempo de execução para reduzir latência e custos. Um módulo Nuxt que automatiza a descoberta e o registro de composables e server routes, adicionando um cache configurável para execução SSR, resolve tudo isso centralmente.

O problema se manifesta em tempos de build maiores, imports espalhados pelo projeto, dificuldade em migrar estruturas de pastas e inconsistências entre ambientes de desenvolvimento e produção. O cache SSR também é frequentemente implementado de forma ad-hoc em cada rota, o que impede uma estratégia unificada e a possibilidade de trocar backend de cache (em memória, redis, etc.) sem tocar a lógica das rotas.

Um módulo bem desenhado deve oferecer: descoberta automática de arquivos, integração com o sistema de auto-import nativo do Nuxt, registro programático das rotas de servidor, wrapper de cache com TTL configurável e opções para usar armazenamento externo em produção.

Contexto de uso

Projetos que servem conteúdo dinâmico do servidor, consomem micro-serviços ou fazem SSR com dados de terceiros vão se beneficiar mais. O módulo é voltado a times com 1-3 anos de experiência em Nuxt, que já conhecem composables, server routes e o ciclo de build do Nuxt 3/ Nitro.

Impacto

Com o módulo, você reduz boilerplate, centraliza estratégia de cache, melhora tempo de resposta em SSR e facilita testes e observabilidade das rotas server. Em ambientes escaláveis, o módulo permite trocar cache em memória por Redis/Memcached sem alterar handlers.

2) Abordagem ingênua vs abordagem otimizada

Abordagem ingênua: criar um script que importa manualmente cada composable e exporta para index, ou escrever um roteador manual que chama addServerHandler para cada arquivo. Implementar cache diretamente em cada endpoint usando uma Map local e duplicar lógica em vários handlers.

Problemas da abordagem ingênua:

  • Código repetido e espalhado.
  • Dificuldade para alterar políticas de cache (TTL, invalidation).
  • Mais chances de esquecer de registrar um arquivo ou de cometer erros durante refatoração.
  • Não aproveita hooks do Nuxt para integrar com o build e o sistema de auto-import do framework.

Abordagem otimizada

Criar um módulo Nuxt que:

  1. Scaneia diretórios configuráveis (por padrão composables/ e server/api/).
  2. Registra composables com addImportsDir para o auto-import do Nuxt.
  3. Registra server handlers dinamicamente com addServerHandler apontando para os arquivos descobertos.
  4. Fornece um wrapper de runtime para cache com opção de usar uma store local ou um store remoto (Redis), configurável via options do módulo.

Benefícios:

  • Centralização e padronização.
  • Menos erros humanos e menor surface para manutenção.
  • Capacidade de trocar estratégia de cache sem tocar handlers individuais.

Trade-offs

O módulo adiciona complexidade inicial no build e precisa ser bem testado. Caching em memória funciona bem em ambientes com instância única, mas requer external store em clusters. O module deve expor hooks para bypassar cache durante desenvolvimento ou testes.

Decisões de design

Decidimos usar addImportsDir para composables (aproveita resolver do Nuxt), e addServerHandler para rotas. Para cache, o módulo fornece um wrapper runtime que exporta uma função useRouteCache para ser usada dentro de server handlers, e injeção automática de wrapper em handlers gerados pelo module quando desejado.

3) Implementacao com codigo real

Aqui vem o núcleo: um módulo Nuxt em TypeScript que descobre arquivos e registra imports e handlers. O exemplo a seguir mostra a estrutura esperada do pacote do módulo e trechos reais de código.

Estrutura mínima do módulo

Estrutura de pastas do módulo:

  • src/module.ts - definicao do modulo
  • src/runtime/server/cache.ts - runtime de cache para server
  • src/runtime/server/wrapper.ts - wrapper que aplica cache aos handlers
  • package.json

Agora o código do module.ts. Contexto: este arquivo é executado em build time pelo Nuxt Kit; ele encontra arquivos e chama addImportsDir e addServerHandler.

import { defineNuxtModule, addImportsDir, addServerHandler } from '@nuxt/kit'
import { resolve } from 'pathe'
import fg from 'fast-glob'

export interface ModuleOptions {
  composablesDir?: string
  serverDir?: string
  enableCache?: boolean
}

export default defineNuxtModule<ModuleOptions>({
  meta: { name: 'nuxt-auto-composables-server-cache' },
  defaults: { composablesDir: 'composables', serverDir: 'server/api', enableCache: true },
  setup (options, nuxt) {
    const composablesPath = resolve(nuxt.options.srcDir, options.composablesDir)
    const serverPath = resolve(nuxt.options.srcDir, options.serverDir)

    // registra auto-imports para composables
    addImportsDir({ path: composablesPath })

    // descobre handlers server e registra no Nuxt
    const pattern = `${serverPath}/**/*.{ts,js}`
    const files = fg.sync(pattern, { absolute: true })

    for (const file of files) {
      // cada arquivo vira um server handler; rota padrao baseada no path
      const route = '/' + file.replace(serverPath, '').replace(/\.(ts|js)$/, '')
      addServerHandler({ route, handler: file })
    }

    // expor opcoes para runtime via nuxt.options
    nuxt.options.runtimeConfig = nuxt.options.runtimeConfig || {}
    nuxt.options.runtimeConfig.nuxtAuto = { enableCache: options.enableCache }
  }
})

O trecho acima faz auto-import de composables e registra server handlers. Em seguida precisamos do runtime cache que será usado por handlers no servidor.

Runtime: cache simples e adaptável

Contexto: um cache com TTL que pode usar in-memory Map ou um cliente Redis se configurado. Código abaixo cria uma interface simples e implementacoes.

// src/runtime/server/cache.ts
import LRU from 'lru-cache'

export type CacheStore = {
  get(key: string): Promise<any | undefined>
  set(key: string, value: any, ttlMs?: number): Promise<void>
}

export function createInMemoryStore (maxSize = 500) : CacheStore {
  const cache = new LRU({ max: maxSize })
  return {
    async get (key) { return cache.get(key) },
    async set (key, value, ttlMs) { cache.set(key, value, { ttl: ttlMs }) }
  }
}

// opção: hook para criar store redis em producao (exemplo simplificado)
export function createRedisStore (redisClient: any): CacheStore {
  return {
    async get (key) { const v = await redisClient.get(key); return v ? JSON.parse(v) : undefined },
    async set (key, value, ttlMs) { const s = JSON.stringify(value); if (ttlMs) { await redisClient.setEx(key, Math.ceil(ttlMs/1000), s) } else { await redisClient.set(key, s) } }
  }
}

Depois, um wrapper que envolve handlers e aplica cache automaticamente se habilitado.

// src/runtime/server/wrapper.ts
import { createInMemoryStore } from './cache'

const store = createInMemoryStore(1000)

export function withCache (handler: any, opts: { ttlMs?: number } = {}) {
  return async function (event: any) {
    try {
      const key = event.node.req.url || JSON.stringify({ path: event.node.req.url, body: await readBody(event) })
      const cached = await store.get(key)
      if (cached) {
        return cached
      }
      const result = await handler(event)
      await store.set(key, result, opts.ttlMs || 1000 * 60)
      return result
    } catch (err) {
      throw err
    }
  }
}

async function readBody (event: any) {
  try { return await event.request.json() } catch (_) { return null }
}

Explicacao: withCache cria uma chave simples baseada na URL e corpo, tenta recuperar do store e, se não existir, chama o handler e grava o resultado. Em produção, substitua createInMemoryStore por uma store Redis.

4) Testes e validacao

Testar o módulo envolve dois níveis: testes unitários das funções de utilidade e testes de integração dentro de um projeto Nuxt. Vou demonstrar exemplos de ambos.

Testes unitários

Testar a lógica do cache e do wrapper com vitest. Exemplo de teste que valida cache em memória.

// test/cache.spec.ts
import { describe, it, expect } from 'vitest'
import { withCache } from '../src/runtime/server/wrapper'

describe('withCache', () => {
  it('should cache results', async () => {
    let called = 0
    const handler = async () => { called++; return { ok: true, at: Date.now() } }
    const wrapped = withCache(handler, { ttlMs: 1000 })

    const fakeEvent = { node: { req: { url: '/api/test' } }, request: { json: async () => null } }

    const a = await wrapped(fakeEvent)
    const b = await wrapped(fakeEvent)

    expect(a).toEqual(b)
    expect(called).toBe(1)
  })
})

Resultado esperado: o handler é chamado apenas uma vez e a segunda chamada retorna o valor em cache.

Testes de integração com Nuxt

Crie um projeto de teste, instale o módulo localmente via link ou file:, e inicie o Nuxt em modo dev/preview. Valide endpoints via curl ou fetch e verifique cabeçalhos e performance.

# exemplo manual
curl -i http://localhost:3000/api/posts

# repetir para validar cache
curl -i http://localhost:3000/api/posts

Observe tempos de resposta: a primeira chamada pode ser mais lenta por causa de fetchs externos; as subsequentes devem ser mais rápidas e com status 200. Para testar invalidation, ajuste TTL e revalide.

Inclua também testes que injetam um cliente Redis fake para validar que o módulo troca store sem alterar handlers.

Erros comuns e armadilhas

Problemas de escopo e state entre requests

Uma armadilha recorrente é usar variáveis globais sem considerar ambientes com múltiplas instâncias. O cache em memória não é compartilhado entre processos. Em clusters ou serverless, prefira Redis ou outro store distribuído.

Chave de cache inadequada

Gerar chave apenas com URL pode quebrar para endpoints POSTs ou que dependem de headers/auth. Sempre inclua corpo e headers relevantes na chave, mas cuidado com dados sensíveis; não coloque tokens completos na chave sem hashing.

Se estiver usando dados sensíveis no key, aplique hash (ex: sha256) e nunca armazene tokens em texto puro no cache.

Problemas com hot-reload e auto-imports

Durante desenvolvimento, mudanças na pasta de composables ou server/api devem ser refletidas sem reiniciar o Nuxt. addImportsDir utiliza o sistema de resolver do Nuxt; se fizer autogeração de arquivos, garanta que o build watch esteja configurado. Alguns cenários exigem restart do dev server.

5) Consideracoes de producao

Configuração recomendada para produção:

  • Use store distribuída (Redis) para cache se executar múltiplas instâncias.
  • Configure TTLs conservadores e evite cache indevido de respostas erradas (5xx). Use policies que só cacheiem 200/2xx.
  • Monitore cache hit ratio e latência. Exponha métricas via Prometheus ou logs estruturados.

Configurações de invalidation

Design para invalidation pode usar chaves por recurso (ex: posts:{id}) e operação de busting ao atualizar dados. O módulo pode expor funções utilitárias para bustar chaves ou prefixos do cache.

Segurança e observabilidade

Evite armazenar informações PII no cache sem criptografia. Instrumente os wrappers do cache para logar eventos de miss/hit e time-to-live. Uma boa prática é enviar métricas para um sistema de monitoramento e ativar logs detalhados apenas em debug.

Exemplo rápido de uso do módulo em um handler server que depende do wrapper (assuma que o module registra o handler automaticamente; aqui mostramos handler explicitamente usando withCache):

// server/api/posts.get.ts
import { withCache } from '#imports' // assumimos que o runtime exportou essa funcao

export default withCache(async (event) => {
  // busca externa ou DB
  const posts = await fetch('https://jsonplaceholder.typicode.com/posts').then(r => r.json())
  return { status: 200, body: posts.slice(0, 10) }
}, { ttlMs: 1000 * 60 })

Contexto: esse arquivo será auto-registrado como /api/posts. O wrapper aplica cache sem que o handler precise implementar lógica de cache manualmente.

Conclusao e passos praticos

Um módulo Nuxt que auto-importa composables e registra server routes com um sistema de cache SSR resolve problemas reais de manutenção, performance e padronização. A solução apresentada equilibra simplicidade e flexibilidade: auto-import para composables via addImportsDir; registro dinâmico de server handlers via addServerHandler; runtime cache com opção de store local e substituição por Redis em produção.

Passos práticos para adotar:

  1. Crie o módulo baseado nos exemplos e publique como pacote interno ou link local.
  2. Padronize pastas do projeto: composables/ e server/api/ por convenção.
  3. Use cache em memória durante desenvolvimento e Redis/externo em produção.
  4. Adicione monitoramento de cache e políticas de invalidation claras.

Dica final: comece com TTL curtos e monitore o comportamento antes de aumentar. Cache é uma otimização poderosa, mas exige disciplina em invalidation e segurança.

Implementar esse módulo reduz boilerplate, endurece o pipeline de deploy e centraliza a política de cache, facilitando a vida do time e melhorando experiência do usuário em páginas SSR.

Assuntos: Vue.js, composables, server-routes, ssr-cache, modules

Leia também