Routing
Route handlers
A route handler is a function that receives an event object (H3Event) and returns a response.
import type { H3Event } from "nitro";
export default (event: H3Event) => {
return "world";
}
import { defineHandler } from "nitro";
// For better type inference
export default defineHandler((event) => {
return "world";
});
Filesystem routing
Files are automatically mapped to h3 routes — defining a route is as simple as creating a file inside the api/ or routes/ directory of your serverDir.
serverDir (or scanDirs) in your config — no directories are scanned by default.Each file defines a single handler, and you can append the HTTP method to the filename to match a specific request method.
routes/
api/
test.ts <-- /api/test
hello.get.ts <-- /hello (GET only)
hello.post.ts <-- /hello (POST only)
vite.config.ts
You can nest routes by creating subdirectories.
routes/
api/
[org]/
[repo]/
index.ts <-- /api/:org/:repo
issues.ts <-- /api/:org/:repo/issues
index.ts <-- /api/:org
package.json
Route groups
To organize related route files without affecting the resulting URLs, put them in a folder wrapped in parentheses ( and ).
For example:
routes/
api/
(admin)/
users.ts <-- /api/users
reports.ts <-- /api/reports
(public)/
index.ts <-- /api
package.json
Static routes
Create a file in the routes/ or routes/api/ directory — the file path becomes the route path. Then export a route handler:
import { defineHandler } from "nitro";
export default defineHandler(() => {
return { hello: "API" };
});
Dynamic routes
Single param
To define a route with params, use the [<param>] syntax where <param> is the name of the param. The param will be available on the event.context.params object.
import { defineHandler } from "nitro";
export default defineHandler((event) => {
const { name } = event.context.params;
return `Hello ${name}!`;
});
Calling /hello/nitro returns:
Hello nitro!
Multiple params
You can define multiple params in a route using [<param1>]/[<param2>] syntax where each param is a folder. You cannot define multiple params in a single filename or folder.
import { defineHandler } from "nitro";
export default defineHandler((event) => {
const { name, age } = event.context.params;
return `Hello ${name}! You are ${age} years old.`;
});
Catch-all params
You can capture all the remaining parts of a URL using [...<param>] syntax. This will include the / in the param.
import { defineHandler } from "nitro";
export default defineHandler((event) => {
const { name } = event.context.params;
return `Hello ${name}!`;
});
Calling /hello/nitro/is/hot returns:
Hello nitro/is/hot!
Specific request method
You can append the HTTP method to the filename to force the route to be matched only for a specific HTTP request method, for example hello.get.ts will only match for GET requests. You can use any HTTP method you want.
Supported methods: get, post, put, delete, patch, head, options, connect, trace.
// routes/users/[id].get.ts
import { defineHandler } from "nitro";
export default defineHandler(async (event) => {
const { id } = event.context.params;
// Do something with id
return `User profile!`;
});
// routes/users.post.ts
import { defineHandler } from "nitro";
export default defineHandler(async (event) => {
const body = await event.req.json();
// Do something with body like saving it to a database
return { updated: true };
});
Catch-all route
You can create a special route that matches all requests not handled by any other route — useful as a default or fallback route.
To create a catch-all route, create a file named [...].ts.
import { defineHandler } from "nitro";
export default defineHandler((event) => {
return `Hello ${event.url.pathname}!`;
});
[...].ts), the matched wildcard segments are available as event.context.params._.The server entry and renderer also act as catch-all handlers and are chained after file-based catch-all routes.
Environment-specific handlers
You can include a route only in specific builds by adding a .dev, .prod, or .prerender suffix to the filename, for example: routes/test.get.dev.ts or routes/test.get.prod.ts.
The suffix is placed after the method suffix (if any):
routes/
env/
index.dev.ts <-- /env (dev only)
index.get.prod.ts <-- /env (GET, prod only)
routes config.Ignoring files
You can use the ignore config option to exclude files from route scanning. It accepts an array of glob patterns relative to the server directory.
import { defineConfig } from "nitro";
export default defineConfig({
ignore: [
"routes/api/**/_*", // Ignore files starting with _ in api/
"middleware/_*.ts", // Ignore middleware starting with _
"routes/_*.ts", // Ignore root routes starting with _
],
});
Route meta
You can define route metadata at build time using the defineRouteMeta macro in your route handler files.
import { defineRouteMeta } from "nitro";
import { defineHandler } from "nitro";
defineRouteMeta({
openAPI: {
tags: ["test"],
description: "Test route description",
parameters: [{ in: "query", name: "test", required: true }],
},
});
export default defineHandler(() => "OK");
Programmatic route handlers
In addition to filesystem routing, you can register route handlers programmatically using the routes config option.
routes config
The routes option allows you to map route patterns to handlers:
import { defineConfig } from "nitro";
export default defineConfig({
routes: {
"/api/hello": "./server/routes/api/hello.ts",
"/api/custom": {
handler: "./server/routes/api/hello.ts",
method: "POST",
lazy: true,
},
"/virtual": {
handler: "#virtual-route",
},
},
});
Each route entry can be a simple string (handler path) or an object with the following options:
| Option | Type | Description |
|---|---|---|
handler | string | Path to event handler file or virtual module ID |
method | string | HTTP method to match (get, post, etc.) |
lazy | boolean | Use lazy loading to import handler |
format | "web" | "node" | Handler type. "node" handlers are converted to web-compatible |
env | string | string[] | Environments to include this handler ("dev", "prod", "prerender", or a preset name) |
handlers config
The handlers array is useful for registering middleware with control over route matching:
import { defineConfig } from "nitro";
export default defineConfig({
handlers: [
{
route: "/api/**",
handler: "./server/middleware/api-auth.ts",
middleware: true,
},
],
});
Each handler entry supports the following options:
| Option | Type | Description |
|---|---|---|
route | string | HTTP pathname pattern (e.g., /test, /api/:id, /blog/**) |
handler | string | Path to event handler file or virtual module ID |
method | string | HTTP method to match (get, post, etc.) |
middleware | boolean | Run handler as middleware before route handlers |
lazy | boolean | Use lazy loading to import handler |
format | "web" | "node" | Handler type. "node" handlers are converted to web-compatible |
env | string | string[] | Environments to include this handler ("dev", "prod", "prerender", or a preset name) |
Middleware
Middleware run before route handlers, letting you inspect or extend the incoming request — see Lifecycle for where middleware fit in the request pipeline.
Middleware are auto-registered from the middleware/ directory.
middleware/
auth.ts
logger.ts
...
routes/
hello.ts
Simple middleware
Middleware are defined exactly like route handlers, with one exception: they should not return anything.
import { defineHandler } from "nitro";
export default defineHandler((event) => {
// Extend or modify the event
event.context.user = { name: "Nitro" };
});
Middleware in the middleware/ directory are automatically registered for all routes. To register middleware for a specific route pattern, see Route-scoped middleware.
Execution order
Middleware are executed in directory listing order.
middleware/
auth.ts <-- First
logger.ts <-- Second
... <-- Third
Prefix middleware with a number to control their execution order.
middleware/
1.logger.ts <-- First
2.auth.ts <-- Second
3.... <-- Third
10.filename.ts comes right after 1.filename.ts and before 2.filename.ts. If you have more than 10 middleware in the same directory, prefix 1-9 with a 0 (e.g. 01.filename.ts).Request filtering
Global middleware run on every request, but you can add your own conditions to scope them. For example, check the URL to apply a middleware to a specific route:
import { defineHandler } from "nitro";
export default defineHandler((event) => {
// Will only execute for /auth route
if (event.url.pathname.startsWith('/auth')) {
event.context.user = { name: "Nitro" };
}
});
Route-scoped middleware
You can register middleware for specific route patterns using the handlers config with the middleware option and a specific route:
import { defineConfig } from "nitro";
export default defineConfig({
handlers: [
{
route: "/api/**",
handler: "./server/middleware/api-auth.ts",
middleware: true,
},
],
});
Unlike global middleware (registered in the middleware/ directory which match /**), route-scoped middleware only run for requests matching the specified pattern.
Error handling
You can use the utilities available in H3 to handle errors in both route handlers and middleware.
The way errors are sent back to the client depends on the environment. In development, requests with an Accept header of text/html (such as browsers) receive an HTML error page. In production, errors are always sent as JSON. This behavior can be influenced by request properties such as the Accept or User-Agent headers.
To capture errors globally with the error hook, see Lifecycle.
Code splitting
Nitro creates a separate chunk for each route handler. Chunks load on-demand when first requested, so /api/users doesn't load code for /api/posts.
See inlineDynamicImports to bundle everything into a single file.
Route rules
Route rules let you attach behavior — redirects, proxying, caching, authentication, custom headers — to route patterns directly from your configuration, without writing handler code.
Each rule maps a route pattern (using rou3 syntax) to a set of options. When the cache option is set, matching handlers are automatically wrapped with defineCachedHandler — see the cache guide to learn more.
Set route rules with the routeRules config option:
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
'/blog/**': { swr: true },
'/blog2/**': { swr: 600 },
'/blog3/**': { static: true },
'/blog4/**': { cache: { /* cache options*/ } },
'/assets/**': { headers: { 'cache-control': 's-maxage=0' } },
'/api/v1/**': { cors: true, headers: { 'access-control-allow-methods': 'GET' } },
'/old-page': { redirect: '/new-page' },
'/old-page/**': { redirect: '/new-page/**' },
'/proxy/example': { proxy: 'https://example.com' },
'/proxy/**': { proxy: '/api/**' },
'/admin/**': { basicAuth: { username: 'admin', password: 'supersecret' } },
}
});
Rule merging and overrides
Route rules are matched from least specific to most specific. When multiple rules match a request, their options are merged, with more specific rules taking precedence.
You can use false to disable a rule that was set by a more general pattern:
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
'/api/cached/**': { swr: true },
'/api/cached/no-cache': { cache: false, swr: false },
'/admin/**': { basicAuth: { username: 'admin', password: 'secret' } },
'/admin/public/**': { basicAuth: false },
}
});
Method-scoped rules
Prefix a rule key with an uppercase HTTP method (followed by a space) to scope it to requests using that method. Keys without a method prefix apply to every method. A method-scoped rule is merged on top of the method-agnostic rules that match the same path, so you can layer method-specific behavior over shared defaults:
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
// Applies to every method
'/api/**': { headers: { 'x-api': 'true' } },
// Only POST requests to /api/** additionally require auth
'POST /api/**': { basicAuth: { username: 'admin', password: 'secret' } },
// Cache GET requests to /feed only
'GET /feed': { swr: 600 },
}
});
_headers & _redirects, Vercel config.json) does not split by method, so prefer method-agnostic keys for headers/redirect/proxy rules you expect a platform to emit into its static config.Headers
Set custom response headers for matching routes:
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
'/api/**': { headers: { 'cache-control': 's-maxage=60' } },
'**': { headers: { 'x-powered-by': 'Nitro' } },
}
});
CORS
Handle CORS at runtime with the cors rule. cors: true applies permissive defaults (origin, methods, and allowed headers *): a simple request gets access-control-allow-origin: * and access-control-expose-headers: *, and an OPTIONS preflight is answered directly (204) with the matching access-control-allow-* headers.
handleCors), and not from the static/CDN config.
On platforms that can serve prerendered/static assets straight from the edge, CORS headers are only added when the request reaches the server handler.Pass an object for finer control — an origin allowlist, credentials, maxAge, etc. (h3 CorsOptions). Combining credentials: true with a wildcard origin is invalid and throws at build time:
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
// Permissive defaults
'/api/public/**': { cors: true },
// Restrict to specific origins with credentials
'/api/v1/**': {
cors: { origin: ['https://app.example.com'], credentials: true },
},
}
});
Redirect
Redirect matching routes to another URL. Use a string for a simple redirect (defaults to 307 status), or an object for more control:
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
// Simple redirect (307 status)
'/old-page': { redirect: '/new-page' },
// Redirect with custom status
'/legacy': { redirect: { to: 'https://example.com/', status: 308 } },
// Wildcard redirect — preserves the path after the pattern
'/old-blog/**': { redirect: 'https://blog.example.com/**' },
}
});
Proxy
Proxy requests to another URL. Supports both internal and external targets:
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
// Proxy to exact URL
'/api/proxy/example': { proxy: 'https://example.com' },
// Proxy to internal route
'/api/proxy/**': { proxy: '/api/echo' },
// Wildcard proxy — preserves the path after the pattern
'/cdn/**': { proxy: 'https://cdn.jsdelivr.net/**' },
// Proxy with options
'/external/**': {
proxy: {
to: 'https://api.example.com/**',
// Additional H3 proxy options...
},
},
}
});
Basic auth
Protect routes with HTTP Basic Authentication:
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
'/admin/**': {
basicAuth: {
username: 'admin',
password: 'supersecret',
realm: 'Admin Area', // Optional, shown in the browser prompt
},
},
// Disable basic auth for a sub-path
'/admin/public/**': { basicAuth: false },
}
});
Caching (SWR / Static)
Control caching behavior with cache, swr, or static options:
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
// Enable stale-while-revalidate caching
'/blog/**': { swr: true },
// SWR with maxAge in seconds
'/blog/posts/**': { swr: 600 },
// Full cache options
'/api/data/**': {
cache: {
maxAge: 60,
swr: true,
// ...other cache options
},
},
// Disable caching
'/api/realtime/**': { cache: false },
}
});
swr: true is a shortcut for cache: { swr: true } and swr: <number> is a shortcut for cache: { swr: true, maxAge: <number> }.Prerender
Mark routes for prerendering at build time:
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
'/about': { prerender: true },
'/dynamic/**': { prerender: false },
}
});
ISR (Vercel)
Configure Incremental Static Regeneration for Vercel deployments:
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
'/isr/**': { isr: true },
'/isr-ttl/**': { isr: 60 },
'/isr-custom/**': {
isr: {
expiration: 60,
allowQuery: ['q'],
group: 1,
},
},
}
});
Route rules reference
| Option | Type | Description |
|---|---|---|
headers | Record<string, string> | Custom response headers |
redirect | string | { to: string, status?: number } | Redirect to another URL (default status: 307) |
proxy | string | { to: string, ...proxyOptions } | Proxy requests to another URL |
cors | boolean | CorsOptions | Handle CORS via h3's handleCors (true = permissive) |
cache | object | false | Cache options (see cache guide) |
swr | boolean | number | Shortcut for cache: { swr: true, maxAge: number } (false resets an inherited cache rule) |
static | boolean | number | Shortcut for static caching |
basicAuth | { username, password, realm? } | false | HTTP Basic Authentication |
prerender | boolean | Enable/disable prerendering |
isr | boolean | number | object | Incremental Static Regeneration (Vercel) |
Runtime route rules
Route rules can be provided through runtimeConfig, allowing overrides via environment variables without rebuilding:
import { defineConfig } from "nitro";
export default defineConfig({
runtimeConfig: {
nitro: {
routeRules: {
'/api/**': { headers: { 'x-env': 'production' } },
},
},
},
});
Config reference
These config options control routing behavior:
| Option | Type | Default | Description |
|---|---|---|---|
baseURL | string | "/" | Base URL for all routes |
apiBaseURL | string | "/api" | Base URL for routes in the api/ directory |
apiDir | string | "api" | Directory name for API routes |
routesDir | string | "routes" | Directory name for file-based routes |
serverDir | string | false | false | Server directory for scanning routes, middleware, plugins, etc. |
scanDirs | string[] | [] | Additional directories to scan for routes |
routes | Record<string, string | handler> | {} | Route-to-handler mapping |
handlers | NitroEventHandler[] | [] | Programmatic handler registration (mainly for middleware) |
routeRules | Record<string, NitroRouteConfig> | {} | Route rules for matching patterns |
ignore | string[] | [] | Glob patterns to ignore during file scanning |