"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.
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.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.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 }).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/*.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.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.$fetch and useFetch in the app; defineRouteMeta({ openAPI }) documents routes for Nitro's OpenAPI output.async handlers must not block the loop with CPU work.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.A complete resource with validation, context from middleware, status codes and headers:
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
})
declare module 'h3' {
interface H3EventContext {
requestId: string
user: { id: string, role: 'admin' | 'customer' } | null
}
}
export {}
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 }
})
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:
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()
},
})
})
// 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)
})
})
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 }
})
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.
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.
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.
curl and check the response bodies.H3EventContext augmentation; confirm event.context.user is typed inside handlers.process.memoryUsage() logged every second in a plugin."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."
Nothing yet. Notes filed here show up on this page and the section overview.
Fullstack with Nitro
What "fullstack Nuxt" means in 2026 — the server directory as a real backend on Nitro, when it is enough and when a separate service is warranted — and how the pages on routes and h3, databases, auth, server caching, files, realtime and background work, API design and deployment fit together.
Data layer and databases
Choosing Postgres, SQLite or a KV, Drizzle versus Prisma versus Kysely, migrations in CI, connection pooling in serverless and edge, transactions and isolation, N+1 and indexes, keyset pagination, optimistic concurrency, DTOs, and Nitro's storage layer for KV and blobs.