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.
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.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.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.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.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.event.context, typed by augmenting H3EventContext. A module-scope let in a server util is shared by every request the process handles.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.$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.defineEventHandler, getQuery, getRouterParam, readBody, readValidatedBody(event, schema.parse), getCookie / setCookie, setResponseStatus, createError, sendRedirect, defineLazyEventHandler for handlers with expensive setup.defineTask in server/tasks, enabled with nitro.experimental.tasks, scheduled with nitro.scheduledTasks — useful for a toolkit's cache warm-up or index rebuild.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.Route rules are the cheapest per-route lever, and a module can add them without touching the consumer's config:
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:
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:
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:
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') }])
}
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.
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.
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.
/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.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.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.NITRO_PRESET=node-server and again with NITRO_PRESET=cloudflare-module; diff the .output tree and note which files only exist in one."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."
The startup sequence
What actually happens between typing nuxt dev and getting a URL — c12 config loading, schema resolution, the hookable instance, module order, app resolution, the Vite and Nitro builds.
The unjs toolbelt
The unjs packages Nuxt is assembled from, what each one does for a module or layer author, and where you actually meet them while writing a toolkit.