Fullstack with Nitro

Server routes and h3

Everything a Nitro handler can do — routing by file, params, query and body with validation, headers and status, cookies, event.context set in server middleware, plugins and hooks, lazy handlers, streaming responses, in-process $fetch, typed routes — and what changes with h3 v2 and nuxt/server.

"Write an endpoint that…" is the fullstack warm-up, and it tests fluency: where the file goes, how you read a param and a body safely, how you set a status and a header, where the authenticated user comes from. This page is the h3 and Nitro vocabulary for Nuxt 4 (Nitro 2, h3 v1 APIs), with the h3 v2 changes noted.

Know

  • Routing by file: server/api/products.get.ts → GET /api/products; server/routes/sitemap.xml.get.ts → /sitemap.xml; method suffixes .get/.post/.put/.patch/.delete; [id].ts params, [...slug].ts catch-all, index.ts for the folder root. Without a method suffix the handler answers every method.
  • The handler: defineEventHandler(async (event) => value); returned objects become JSON, strings become text, null/undefined → 204 via sendNoContent, a ReadableStream/Node stream is streamed, Response objects pass through. defineLazyEventHandler(() => handler) defers expensive setup to first request.
  • Reading input, validated: getRouterParam(event, 'id')/getRouterParams, getQuery, readBody, readRawBody, readFormData (web FormData), readMultipartFormData (uploads), and the validated versions getValidatedRouterParams, getValidatedQuery, readValidatedBody that take a Standard Schema parser and throw a 400 with issues. event.method, event.path, getRequestURL(event), getRequestIP(event, { xForwardedFor }).
  • Writing output: setResponseStatus(event, 201, 'Created'), setResponseHeader(s), appendResponseHeader (for multiple Set-Cookie), sendRedirect(event, url, 302), sendStream, sendNoContent, setCookie/getCookie/deleteCookie, createError({ status, statusText, data }), createEventStream for SSE, proxyRequest for proxying.
  • event.context carries per-request state: server middleware (server/middleware/*.ts, runs on every request, must not return a response) populates event.context.user, requestId, tenant; handlers read it; types come from augmenting H3EventContext. event.context.params holds route params; platform presets add event.context.cloudflare etc.
  • Server plugins (server/plugins/*.ts, defineNitroPlugin((nitroApp) => {})) run once at startup: connect pools, register hooks (request, beforeResponse, afterResponse, render:html, error, close), start schedulers. useNitroApp() gives access to hooks and the app from anywhere on the server.
  • Utilities: server/utils/*.ts are auto-imported into every server file (useDb, requireRole); #server alias (4.3) for explicit imports; shared/ for code both sides use; useRuntimeConfig(event) for config; useStorage(base) for KV and blobs; event.$fetch / $fetch to call other routes in-process (no HTTP) with event.$fetch forwarding the request context.
  • Typed routes: handler return types feed $fetch and useFetch in the app; defineRouteMeta({ openAPI }) documents routes for Nitro's OpenAPI output.
  • Concurrency reality: one process serves many requests; anything at module scope is shared (a pool is fine, a request-specific variable is a leak); async handlers must not block the loop with CPU work.
  • h3 v2 and Nitro 3 (Nuxt 5 Nuxt 5 ): web-standard Request/Response, event.req as a Request, defineHandler naming, utilities that return Responses; nuxt/server (from Nuxt 4.6) exports the common utilities (defineEventHandler, getQuery, …) in a way that works under Nitro 2 and 3, so handlers written against it survive the major.

How it works

A complete resource with validation, context from middleware, status codes and headers:

server/middleware/context.ts
export default defineEventHandler(async (event) => {
  event.context.requestId = getHeader(event, 'x-request-id') ?? crypto.randomUUID()
  setResponseHeader(event, 'x-request-id', event.context.requestId)
  const session = await getUserSession(event) // nuxt-auth-utils; empty object when anonymous
  event.context.user = session.user ?? null
})
server/types/h3.d.ts
declare module 'h3' {
  interface H3EventContext {
    requestId: string
    user: { id: string, role: 'admin' | 'customer' } | null
  }
}
export {}
server/api/products/index.get.ts
import { z } from 'zod'

const query = z.object({
  q: z.string().trim().max(100).default(''),
  cursor: z.string().uuid().optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20),
})

export default defineEventHandler(async (event) => {
  const { q, cursor, limit } = await getValidatedQuery(event, query.parse) // 400 with issues on failure
  const page = await listProducts(useDb(), { q, cursor, limit })
  setResponseHeader(event, 'cache-control', 'public, s-maxage=60, stale-while-revalidate=300')
  return { items: page.items.map(toProductDto), next: page.nextCursor }
})
server/api/products/[id].patch.ts
import { z } from 'zod'
import { updateProductSchema } from '#shared/schemas/product'

export default defineEventHandler(async (event) => {
  const { id } = await getValidatedRouterParams(event, z.object({ id: z.string().uuid() }).parse)
  if (event.context.user?.role !== 'admin') throw createError({ status: 403, statusText: 'Forbidden' })
  const changes = await readValidatedBody(event, updateProductSchema.parse)
  const ifMatch = getHeader(event, 'if-match')
  const product = await updateProduct(useDb(), id, changes, { expectedVersion: ifMatch }) // 412 inside when versions differ
  setResponseHeader(event, 'etag', `"${product.version}"`)
  return toProductDto(product)
})

Streaming, lazy setup and in-process calls:

server/api/export.get.ts
export default defineEventHandler(async (event) => {
  setResponseHeaders(event, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="products.csv"' })
  const encoder = new TextEncoder()
  return new ReadableStream({
    async start(controller) {
      controller.enqueue(encoder.encode('id,name\n'))
      for await (const row of iterateProducts(useDb())) controller.enqueue(encoder.encode(`${row.id},"${row.name.replaceAll('"', '""')}"\n`))
      controller.close()
    },
  })
})
server/api/search.get.ts
// Expensive index built on first request, once per process.
export default defineLazyEventHandler(async () => {
  const index = await buildSearchIndex(useDb())
  return defineEventHandler(async (event) => {
    const { q } = await getValidatedQuery(event, z.object({ q: z.string().min(1) }).parse)
    return index.search(q)
  })
})
server/api/dashboard.get.ts
export default defineEventHandler(async (event) => {
  // In-process calls to sibling routes; event.$fetch forwards this request's context (cookies, headers).
  const [orders, products] = await Promise.all([event.$fetch('/api/orders?limit=5'), event.$fetch('/api/products?limit=5')])
  return { orders, products }
})
Gotcha· Server middleware that returns

server/middleware runs for every request, including static assets and Nuxt pages. Returning a value or sending a response from it short-circuits everything; use it to set event.context and headers, and put logic that belongs to specific routes in the routes.

Gotcha· event.node.req

Reaching for event.node.req and event.node.res ties the handler to the Node preset and breaks on Cloudflare or Vercel Edge; h3's utilities (getHeader, readBody, setResponseHeader) are platform-neutral, and nuxt/server keeps them stable across h3 versions.

Verify before the interview:

Which h3 major Nuxt 4.5 uses (v1) and the v2 utility names; the nuxt/server availability (from Nuxt 4.6) and whether event.$fetch remains the recommended way to call sibling routes.

docs ↗

Exercise

Exercise
  • Build the products resource above (list, get, patch) with validated params, query and body; test 400s with curl and check the response bodies.
  • Add the context middleware and the H3EventContext augmentation; confirm event.context.user is typed inside handlers.
  • Stream a 100,000-row CSV export and watch memory stay flat with process.memoryUsage() logged every second in a plugin.

Be able to say

Be able to say· Walk me through writing a production-quality endpoint in Nuxt.

"A file under server/api named for the route and method, exporting defineEventHandler. First I read inputs through the validated helpers, getValidatedRouterParams, getValidatedQuery, readValidatedBody, with a zod schema from shared/, so bad input is a 400 with field issues before any logic runs. The authenticated user comes from event.context, populated by a server middleware from the session, and the handler checks authorisation for the specific object. The business rule lives in a service in server/utils that takes the database as a parameter, so it is unit-testable and reusable from tasks. Then I set the status and headers, 201 with a Location, cache headers where the response is shared, an ETag where clients can do conditional updates, and return a DTO rather than a raw row. Nothing request-specific lives at module scope, CPU-heavy work does not block the loop, and the handler's return type is what the page's useFetch sees."

Your notes

Open in Notes

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