Routing & Event Handlers
Nitro maps files in routes/ and api/ to HTTP routes at build time (no runtime router). Handlers receive an H3 v2 event and should return the response body or throw an error.
v3: filesystem routing only runs when
serverDir(orscanDirs) is set — no directories are scanned by default. Auto-imports are removed, so importdefineHandler/HTTPErrorexplicitly.
Event handlers
import { defineHandler } from "nitro";
export default defineHandler((event) => {
return { hello: "world" };
});defineHandler gives type inference. A plain (event) => ... function also works. The event is web-standard based:
event.req // web Request
event.res // response init (headers, status)
event.url // URL object (event.url.pathname, event.url.searchParams)
event.path // request path
event.method // HTTP method
event.context // mutable per-request context (params, custom data)
event.context.params // route paramsRead the body with native Request methods (H3 v2 dropped readBody):
const json = await event.req.json();
const text = await event.req.text();
const form = await event.req.formData();Filesystem routing
Files in api/ (served under /api) or routes/ (served under /) become routes. One handler per file.
routes/
hello.ts -> /hello
api/
test.ts -> /api/test
[org]/
[repo]/
index.ts -> /api/:org/:repo
issues.ts -> /api/:org/:repo/issuesHTTP method suffix
Append the method to match only that verb (get, post, put, delete, patch, head, options, query, connect, trace):
import { defineHandler } from "nitro";
export default defineHandler(async (event) => {
const body = await event.req.json();
return { created: body };
});Dynamic params
export default defineHandler((event) => {
const { name } = event.context.params!;
return `Hello ${name}!`;
});- Multiple params: each as its own folder/segment
[a]/[b](not in one filename). - Catch-all:
[...name].tscaptures the rest of the path (includes/). - Global catch-all:
[...].tsmatches all otherwise-unmatched routes; its wildcard is onevent.context.params._. It chains before the server entry and renderer.
Route groups & environment handlers
- Parenthesized folders
(admin)/group files without affecting the URL. - Suffix
.dev,.prod, or.prerender(after the method suffix) to include a handler only in that build:test.get.prod.ts. ignore: ["routes/**/_*"]config excludes files from scanning.
Middleware
Files in middleware/ run on every request before route matching. They modify the event and must not return (returning ends the request).
import { defineHandler } from "nitro";
export default defineHandler((event) => {
event.context.user = { name: "Nitro" };
});Control execution order with numeric prefixes (1.logger.ts, 2.auth.ts — pad to 01. etc. beyond 9 to keep string sort correct). Scope manually with event.url.pathname, or register route-scoped middleware in config (keep the file outside middleware/ so it isn't also registered globally):
export default defineConfig({
handlers: [
{ route: "/api/**", handler: "./utils/api-auth.ts", middleware: true },
],
});Programmatic routes
Register handlers/middleware in config in addition to (or instead of) the filesystem:
export default defineConfig({
routes: {
"/api/hello": "./routes/api/hello.ts",
"/api/custom": { handler: "./routes/custom.ts", method: "POST", lazy: true },
},
handlers: [
{ route: "/blog/**", handler: "./handlers/blog.ts", method: "get" },
],
});Handler options: handler, method, lazy, middleware, format ("web" | "node"), env.
Error handling
Throw HTTPError (replaces v2 createError):
import { defineHandler, HTTPError } from "nitro";
export default defineHandler((event) => {
const user = findUser(event.context.params!.id);
if (!user) {
throw new HTTPError({ status: 404, message: "User not found" });
}
return user;
});HTTPError has several forms: new HTTPError("msg", { status: 400 }), HTTPError.status(400, "Bad Request"), or the full object form. Any other thrown value is treated as unhandled (always 500, message/stack hidden).
In dev, browsers (Accept: text/html) get an HTML error page; production always returns JSON ({ error, status, message, data }). Customize with the errorHandler config (a path or array of paths) to a module exporting defineErrorHandler((error, event) => Response) from nitro. Handlers run in order (first response wins; built-in default is always appended); devErrorHandler overrides dev-only rendering.
Route rules
Apply per-route behavior (caching, headers, redirects, proxy, CORS) by glob pattern (rou3 syntax). Rules merge least-specific to most-specific; set a rule to false to disable an inherited one. When cache is set, matching handlers are auto-wrapped with defineCachedHandler.
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
"/blog/**": { swr: 600 }, // stale-while-revalidate w/ maxAge
"/api/data/**": { cache: { maxAge: 60 } }, // full cache options
"/api/realtime/**": { cache: false }, // disable caching
"/assets/**": { headers: { "cache-control": "s-maxage=0" } },
"/api/public/**": { cors: true }, // permissive CORS defaults
"/api/v1/**": { cors: { origin: ["https://app.example.com"], credentials: true } },
"/old-page": { redirect: "/new-page" }, // 307 by default
"/legacy": { redirect: { to: "https://example.com/", status: 308 } },
"/old-blog/**": { redirect: "https://blog.example.com/**" }, // wildcard preserves suffix
"/proxy/**": { proxy: "https://api.example.com/**" },
"/about": { prerender: true },
"/isr/**": { isr: 60 }, // Vercel ISR
},
});Route rule keys: headers, redirect, proxy, cors, cache, swr, static, prerender, isr. swr: true = cache: { swr: true } (1s default maxAge); swr: <n> adds maxAge: <n>. Rules can also be supplied via runtimeConfig.nitro.routeRules for env-var overrides without rebuilding.
No
auth/basicAuthroute rule. Auth needs executable logic → use middleware. For a single route use h3'sbasicAuthfromnitro/h3in the handler'smiddlewarearray:import { defineHandler } from "nitro"; import { basicAuth } from "nitro/h3"; export default defineHandler({ middleware: [basicAuth({ username: "admin", password: "secret" })], handler: (event) => `Hello, ${event.context.basicAuth?.username}!`, });
Method-scoped rules
Prefix a key with an uppercase HTTP method + space to scope it; unprefixed keys apply to every method and are merged underneath:
routeRules: {
"/api/**": { headers: { "x-api": "true" } }, // every method
"POST /api/**": { headers: { "x-write": "true" } },
"GET /feed": { swr: 600 },
}Platform-native static config (Netlify/Cloudflare
_headers/_redirects, Vercelconfig.json) does not split by method — prefer method-agnostic keys forheaders/redirect/proxyyou expect emitted statically. Do not combineprerenderandisron the same route.
Key Points
- Handlers return the body or throw; H3 v1
send*helpers are gone. - Use
event.req.json()/text()/formData()instead of v2readBody. - Params live on
event.context.params(unnamed catch-all onparams._). - Each route handler is a separate code-split chunk (set
inlineDynamicImports: trueto bundle into one file). - Route rules wrap matching handlers in caching, proxying, redirects, and CORS without handler code — auth goes in middleware.