Skip to content
Deep diveNuxt

What Nitro and h3 do under your Nuxt server routes

You write defineEventHandler in server/api/, and the same file runs on Node, on a worker or at build time. Here is which layer does what in Nuxt 4.5, and what is planned to change.

vuejs.school team27 Aug 20267 min readNuxt 4.5 · Nitro 2.13 · h3 1.15

A file in server/api/ with one exported function becomes an HTTP endpoint. It reloads as you edit it, and it deploys to a Node server, a Cloudflare worker or a folder of static files without a code change. Three projects share that work. Knowing which one does what tells you where to look when a route misbehaves, which docs to read, and which of your code will survive the next major version.

The examples use Nuxt 4.5 with Vue code in app/ and the server/ folder at the project root, where it has always been.

Which layer does what

  • Nuxt builds the Vue app and drives Nitro. It points Nitro at your server/ folder, starts it in development and at build time, and includes the route types Nitro generates, which is how useFetch('/api/products/1') knows what the route returns.
  • Nitro is the server builder and runtime. It scans server/api/, server/routes/, server/middleware/ and server/plugins/, bundles your server code into .output/, matches requests to handlers, provides storage and caching, and shapes the output for a host through a preset.
  • h3 is the small HTTP framework inside Nitro. It creates an event object for each request and supplies the helpers that read from it and write to it.

Nuxt 4.5 pulls both in through its @nuxt/nitro-server package, which depends on nitropack ^2.13.4 and h3 ^1.15.11. Run npm ls nitropack h3 to see the exact versions in your project.

One detail of the plumbing saves you a request. During server rendering, useFetch('/api/products/1') does not go out over the network: Nitro calls the handler directly as a function. In the browser, the same call is a normal HTTP request.

Write a handler and read the event

Here is a complete route that returns one product or a 404:

server/api/products/[id].get.ts
const products = [
  { id: 1, name: 'Desk lamp', price: 39 },
  { id: 2, name: 'Notebook', price: 6 },
]

export default defineEventHandler((event) => {
  const id = Number(getRouterParam(event, 'id'))
  const product = products.find(p => p.id === id)

  if (!product) {
    throw createError({ statusCode: 404, statusMessage: 'Product not found' })
  }

  return product
})

The file name does the routing. [id] becomes a parameter, which getRouterParam(event, 'id') reads as a string (hence the Number()). The .get suffix limits the route to GET requests, so a POST to the same path never reaches this handler.

defineEventHandler comes from h3. It marks the function as a handler and gives it types; it does not change what the function does. What you return becomes the response: an object or an array is sent as JSON, a string as HTML (text/html). A thrown createError becomes a response with that status code, and any other uncaught error becomes a 500.

The event is h3's description of one request:

  • event.method is the method in capitals (GET).
  • event.path is the path with its query string (/api/products/1?sort=price).
  • event.headers holds the request headers.
  • event.context is a plain object for your own data. h3's router puts route parameters there under params.

There are also event.node.req and event.node.res, the raw Node.js request and response. Outside Node.js and Bun, h3 fakes them with a compatible shim, and the h3 docs ask you to avoid them and prefer the helpers. The last section explains why that advice matters for the next major version.

Where the auto-imports come from

Nothing in that file is imported, yet every name is typed. The names come from three places:

  1. h3's helpers: defineEventHandler, getRouterParam, getQuery, readBody, getCookie, setCookie, setResponseStatus, createError, sendRedirect and the rest. Nitro auto-imports all of h3's utilities, so the h3 v1 docs are the reference.
  2. Nitro's own utilities: useStorage, useRuntimeConfig, defineCachedEventHandler, defineCachedFunction and defineNitroPlugin, listed in the Nitro v2 docs.
  3. Your server/utils/ folder: every export there is available in every server file.

The app's auto-imports stop at the folder boundary: ref, useFetch and your app/composables/ are not auto-imported in server/. The Nuxt docs describe the server as running in a different context from app/, with its own auto-imports and types. Code that both sides need goes in shared/, which cannot import Vue or Nitro code.

Middleware runs before every route

Every file in server/middleware/ runs on every request, before any route, page requests included. Use it to read something once and put it on event.context for the routes:

server/middleware/session.ts
export default defineEventHandler(async (event) => {
  const token = getCookie(event, 'session')
  if (!token) return

  event.context.userId = await useStorage('data').getItem<string>(`sessions:${token}`)
})

Any route can then read the value:

server/api/me.get.ts
export default defineEventHandler((event) => {
  const userId = event.context.userId
  if (!userId) {
    throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
  }
  return { userId }
})

Two rules follow. Keep middleware cheap, because it runs for every page and API request that reaches the server. And never return a value from it: middleware should inspect or extend the context, or throw. A returned value becomes the response, and the route never runs. A bare return, as in the session check above, is fine.

Store data with useStorage

useStorage() returns an unstorage instance: one key-value API (getItem, setItem, removeItem, getKeys) over many drivers, such as memory, a folder or Cloudflare KV. A mount gives a driver a name, and useStorage('data') works through the mount called data. That mount writes to .data/kv in development and in production with a Node preset; Nitro sets it up when you have not configured data yourself.

On a worker there is no disk to write to, so that default does not apply and unconfigured storage lives in memory, gone when the instance stops. Mount a real driver for production and keep the folder for development:

nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    storage: {
      data: { driver: 'cloudflare-kv-binding', binding: 'DATA' },
    },
    devStorage: {
      data: { driver: 'fs', base: './.data/kv' },
    },
  },
})

binding: 'DATA' is the name of the KV namespace binding in your Wrangler configuration. The middleware above does not change. It still calls useStorage('data'); only the driver behind the name differs per environment.

Cache shared responses with defineCachedEventHandler

Some routes do slow work whose result is the same for everyone, like calling a third-party API. Nitro can store the response and reuse it:

server/api/rates.get.ts
export default defineCachedEventHandler(async () => {
  const rates = await $fetch<Record<string, number>>('https://api.example.com/rates')
  return { rates, fetchedAt: new Date().toISOString() }
}, { maxAge: 60 * 10 })

The first request runs the handler and saves the response in the cache mount, which is memory in production and a folder in development unless you mount something else. For the next ten minutes (maxAge is in seconds) requests get the saved copy. After that, because swr (stale-while-revalidate) defaults to true, the next request still gets the old copy at once while Nitro refreshes it in the background. Set swr: false if callers must wait for fresh data.

The mistake to avoid: according to the cache docs, all incoming request headers are dropped when Nitro handles a cached response, unless you list them in varies. A cached handler that reads the session cookie sees no cookie. Cache data that is the same for everyone, and keep per-user routes uncached.

The same cache also works as a route rule in nuxt.config.ts, which applies it to a path or a pattern of paths without changing the handler: routeRules: { '/api/rates': { cache: { maxAge: 600 } } }.

Deploy the same code with a preset

A preset is the part of Nitro that knows a host. The default is node-server, a Node.js server you start yourself:

npx nuxt build
node .output/server/index.mjs

The same project as a Cloudflare worker:

NITRO_PRESET=cloudflare_module npx nuxt build

Nitro bundles the server for the workers runtime and prints the commands to preview and deploy the build when it finishes. On Vercel, Netlify, Cloudflare and a few other providers you can skip the variable: Nitro detects the provider in CI and picks the preset itself.

The third kind of host is no server at all. npx nuxt generate pre-renders your pages to HTML in .output/public, starting from / and following links. Your server routes run during that build, so data fetched while rendering is baked into the pages, and then there is no server. A POST route, a session check or anything that reads the request at runtime does not exist after you deploy. If a page needs one, you need a preset with a server.

What Nitro 3 and h3 2 will change

Everything above describes Nitro 2 and h3 1. The next generation exists, but as pre-releases:

  • h3 2 is a release candidate. Its beta announcement describes a rewrite on web standards: event.req is a standard Request, so you read event.req.headers.get('accept') and await event.req.json(). The same post says most utilities preserved backward compatibility.
  • Nitro 3 has been in public beta since 11 March, under a new package name, nitro, replacing nitropack. It is built on h3 2.
  • Nuxt 5 is where they meet Nuxt. The Nuxt 4.0 announcement planned Nuxt 5 with Nitro v3 and h3 v2, and the Nuxt 4.5 announcement says the team will now focus on stabilising Nuxt 5. That is a plan, not a release.

What you can do now is write handlers that will move cleanly. Use h3's helpers (getQuery, readBody, getCookie, setResponseStatus) rather than event.node.req and event.node.res: read a cookie with getCookie(event, 'session'), not by parsing event.node.req.headers.cookie. The helpers are the surface meant to carry over; event.node is the Node-specific part that h3 2 replaces with web standards.

Check it stuck3 questions from the Server routes quiz.
Practice now
Was this helpful?

Keep reading