Under the hood

Nitro and h3

The server engine underneath Nuxt — presets and .output, route rules, cached handlers, storage, per-request state, server plugins, and how a module extends all of it.

Nitro is the half of Nuxt that most application developers never open, which makes it a reliable interview filter for a tooling role. A module that ships an API route, a cache, a storage mount or a request-scoped value is a module that has to reason about Nitro's own lifecycle, and about the fact that its code runs in a long-lived process handling many requests, not in a component.

Know

  • What Nitro is. It bundles server/api, server/routes, server/middleware, server/plugins and server/utils together with the Vue SSR render handler into one deployable output. A preset decides the shape of that output: node-server, vercel, netlify, cloudflare-module, static and dozens more, selected by nitro.preset, NITRO_PRESET, or auto-detected from the CI environment.
  • Route rules are per-path behaviour set in nuxt.config's routeRules or from a module via extendRouteRules: prerender, swr, isr, cache, redirect, headers, proxy, cors, and ssr: false to serve a route as a client-only shell. They are matched by the router at request time, so they apply to pages and API routes alike.
  • Caching has two entry points: defineCachedEventHandler / cachedEventHandler wraps a whole response, defineCachedFunction wraps any async function. Options: maxAge, staleMaxAge, swr, getKey, varies, shouldBypassCache, group, name. Behind all of it is unstorage, defaulting to in-memory unless you mount a driver.
  • getKey is the whole game. The cache key is derived from the request unless you supply getKey; an unbounded key (a raw query string, a user id, a timestamp) turns the cache into an unbounded memory leak on a long-lived server. varies lists request headers that must take part in the key, which is how you cache per-locale or per-tenant without caching per-user by accident.
  • Storage. useStorage('cache') reaches an unstorage instance; mounts are declared under nitro.storage (production) and nitro.devStorage (dev, so you can point the same name at the filesystem locally and Redis in production). Mounting the cache base at a shared driver is what makes swr work across instances rather than per-process.
  • Runtime config. Prefer useRuntimeConfig(event) inside a handler: passing the event makes per-request overrides work and avoids ambient-context problems. Never call it in module scope, which runs once at import time.
  • Per-request state belongs on event.context, typed by augmenting H3EventContext. A module-scope let in a server util is shared by every request the process handles.
  • Server plugins (defineNitroPlugin) run once at server start. The hooks they register — request, beforeResponse, afterResponse, error, close, plus Nuxt's render:html and render:response for the SSR renderer — then fire per request. Confusing the two is the standard cross-request state-pollution bug.
  • Internal fetch. $fetch('/api/x') and event.$fetch('/api/x') call your own routes through Nitro's local fetch with no network hop. Only event.$fetch (or useRequestFetch() in a component) forwards the incoming request's cookies and headers — plain $fetch during SSR sends an anonymous request, which is why "my API returns logged-out data during SSR" is such a common ticket.
  • h3 v1 API (Nuxt 4): defineEventHandler, getQuery, getRouterParam, readBody, readValidatedBody(event, schema.parse), getCookie / setCookie, setResponseStatus, createError, sendRedirect, defineLazyEventHandler for handlers with expensive setup.
  • Tasks: defineTask in server/tasks, enabled with nitro.experimental.tasks, scheduled with nitro.scheduledTasks — useful for a toolkit's cache warm-up or index rebuild.
  • Nuxt 5 Nuxt 4 is Nitro 2 with h3 v1. Nuxt 5 targets Nitro v3, built on web-standard Request/Response, where server utilities are imported from nuxt/server rather than auto-imported from h3, and nitro.storage is replaced by build-time virtual modules. Keep layer and module server code on the Nuxt-provided helpers so the migration is a version bump, not a rewrite.

How it works

Route rules are the cheapest per-route lever, and a module can add them without touching the consumer's config:

nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/blog/**': { isr: 3600 },
    '/dashboard/**': { ssr: false },
    '/api/stats': { cache: { maxAge: 60, swr: true } },
    '/legacy/**': { redirect: { to: '/new/**', status: 308 } },
    '/api/public/**': { cors: true, headers: { 'access-control-allow-methods': 'GET' } },
  },
  nitro: {
    storage: { cache: { driver: 'redis', url: process.env.REDIS_URL } },
    devStorage: { cache: { driver: 'fs', base: './.data/cache' } },
  },
})

A cached handler with a bounded key and an explicit varies list:

server/api/rates.get.ts
export default defineCachedEventHandler(async (event) => {
  const { currency } = await getValidatedQuery(event, ratesQuerySchema.parse)
  const config = useRuntimeConfig(event)
  return await $fetch(`${config.ratesApi}/latest`, { query: { base: currency } })
}, {
  name: 'rates',
  maxAge: 60 * 5,
  staleMaxAge: 60 * 60, // serve stale for an hour while revalidating
  swr: true,
  // bounded: one entry per supported currency, never per user or per query string
  getKey: (event) => {
    const { currency } = getQuery(event)
    return SUPPORTED.includes(String(currency)) ? String(currency) : 'unsupported'
  },
  varies: ['accept-language'],
  shouldBypassCache: event => Boolean(getHeader(event, 'x-preview-token')),
})

Per-request state, set once at start, written per request:

server/plugins/request-id.ts
declare module 'h3' {
  interface H3EventContext {
    requestId: string
  }
}

// runs ONCE when the server boots
export default defineNitroPlugin((nitroApp) => {
  // the handler inside runs on EVERY request
  nitroApp.hooks.hook('request', (event) => {
    event.context.requestId = getHeader(event, 'x-request-id') ?? crypto.randomUUID()
  })

  nitroApp.hooks.hook('beforeResponse', (event) => {
    setResponseHeader(event, 'x-request-id', event.context.requestId)
  })
})

And the module-author side — everything a toolkit needs to add to Nitro:

src/module.ts
setup(options, nuxt) {
  const resolver = createResolver(import.meta.url)

  nuxt.hook('nitro:config', (nitroConfig) => {
    // a virtual module the toolkit's server code can import
    nitroConfig.virtual ||= {}
    nitroConfig.virtual['#toolkit/config'] = `export default ${JSON.stringify(options)}`
    // keep a CJS-only dependency out of the rollup graph
    nitroConfig.externals ||= {}
    nitroConfig.externals.inline = [...(nitroConfig.externals.inline || []), resolver.resolve('./runtime/server')]
    nitroConfig.storage ||= {}
    nitroConfig.storage.toolkit = { driver: 'memory' }
  })

  nuxt.hook('nitro:init', (nitro) => {
    // Nitro's own hooks are only available once the instance exists
    nitro.hooks.hook('prerender:generate', (route) => { /* inspect generated routes */ })
  })

  addServerHandler({ route: '/api/_toolkit/health', handler: resolver.resolve('./runtime/server/health') })
  addServerPlugin(resolver.resolve('./runtime/server/plugins/request-id'))
  addServerImports([{ name: 'useToolkit', from: resolver.resolve('./runtime/server/utils/toolkit') }])
}
Gotcha· A Nitro plugin body is not per-request

defineNitroPlugin((nitroApp) => { const seen = new Map() ... }) creates one Map for the process. Every request adds to it and nothing ever removes it; in a container that stays up for days this is a textbook leak, and it also leaks data between users. Anything request-scoped goes on event.context inside a request hook; anything that must be bounded gets an explicit eviction policy or an unstorage mount with a TTL.

Gotcha· $fetch during SSR is anonymous

Calling $fetch('/api/me') in a component's setup during SSR hits your own route with no cookies, so it renders the logged-out view, then the client re-fetches with cookies and renders the logged-in one — a hydration mismatch that only appears for signed-in users. Use useRequestFetch() in components (or event.$fetch in server code) to forward the incoming headers, and remember that forwarding headers blindly to a third-party host leaks the user's cookies.

Verify before the interview:

Kit's Nitro helpers are in motion: check whether the current docs lead with addServerPlugin or a newer addNitroPlugin (with nitro2 / nitro3 variants), and confirm the current names for the Nitro hooks you rely on. For Nuxt 4, nuxt.com's server-directory docs and h3.dev are the accurate references — nitro.build documents v3.

docs ↗

Exercise

Exercise
  • Ship /api/_toolkit/health from a module, returning process.memoryUsage() and event.context.requestId, plus the Nitro plugin that sets the id. Confirm the x-request-id response header round-trips.
  • Add a defineCachedEventHandler route with a bounded getKey, set extendRouteRules('/toolkit-cached', { swr: 60 }), and inspect cache-control and the x-nitro-* headers with curl -I on the first and second request.
  • Mount nitro.devStorage.cache at the filesystem and watch .data/cache fill as you hit the route. Then delete an entry by hand and watch the next request repopulate it.
  • Build the playground with NITRO_PRESET=node-server and again with NITRO_PRESET=cloudflare-module; diff the .output tree and note which files only exist in one.
  • Skim nitro.build/docs on plugins and list every difference from the v2 code you just wrote.

Be able to say

Be able to say· What is Nitro, and how does a module extend it?

"Nitro is the server engine: it takes the server/ directory and the Vue SSR renderer and bundles them into a deployable output shaped by a preset — node-server, a Cloudflare worker, a static site. On top of that it gives you route rules for per-path behaviour, cached handlers and cached functions backed by unstorage, storage mounts, and a plugin system. A module extends it from four places: nitro:config to mutate the config before Nitro is built — virtual modules, externals, storage mounts; nitro:init to register Nitro's own runtime hooks once the instance exists; and the kit helpers addServerHandler, addServerPlugin and addServerImports for routes, plugins and server-side auto-imports. The thing I keep in mind is that a Nitro plugin body runs once at boot while its hooks run per request, so request state goes on event.context, typed by augmenting H3EventContext, never in module scope."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.