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/**', statusCode: 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."