Routing

Nitro supports filesystem routing to automatically map files to routes. Routes are compiled and code-split at build time, removing the need for a runtime router.

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";
}

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.

Filesystem routing requires setting 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
Route groups are not part of the route path and are only used for organization purposes.

Static routes

Create a file in the routes/ or routes/api/ directory — the file path becomes the route path. Then export a route handler:

routes/api/test.ts
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.

routes/hello/[name].ts
import { defineHandler } from "nitro";

export default defineHandler((event) => {
  const { name } = event.context.params;

  return `Hello ${name}!`;
});

Calling /hello/nitro returns:

Response
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.

routes/hello/[name]/[age].ts
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.

routes/hello/[...name].ts
import { defineHandler } from "nitro";

export default defineHandler((event) => {
  const { name } = event.context.params;

  return `Hello ${name}!`;
});

Calling /hello/nitro/is/hot returns:

Response
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!`;
});

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.

routes/[...].ts
import { defineHandler } from "nitro";

export default defineHandler((event) => {
  return `Hello ${event.url.pathname}!`;
});
With an unnamed catch-all ([...].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)
To target multiple environments, or a preset name as the environment, register the route programmatically via the 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.

nitro.config.ts
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.

This feature is currently experimental.
routes/api/test.ts
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");
This feature is currently usable to specify OpenAPI meta. See the Swagger specification for available OpenAPI options.

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:

nitro.config.ts
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:

OptionTypeDescription
handlerstringPath to event handler file or virtual module ID
methodstringHTTP method to match (get, post, etc.)
lazybooleanUse lazy loading to import handler
format"web" | "node"Handler type. "node" handlers are converted to web-compatible
envstring | 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:

nitro.config.ts
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:

OptionTypeDescription
routestringHTTP pathname pattern (e.g., /test, /api/:id, /blog/**)
handlerstringPath to event handler file or virtual module ID
methodstringHTTP method to match (get, post, etc.)
middlewarebooleanRun handler as middleware before route handlers
lazybooleanUse lazy loading to import handler
format"web" | "node"Handler type. "node" handlers are converted to web-compatible
envstring | 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 can modify the request before it is processed, not after.

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.

middleware/auth.ts
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.

Returning a value from middleware closes the request: the value becomes the response and no further handlers run. Avoid returning from middleware — use a route handler instead.

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
File names are sorted as strings, so 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:

middleware/auth.ts
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:

nitro.config.ts
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:

nitro.config.ts
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:

nitro.config.ts
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:

nitro.config.ts
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 },
  }
});
Method matching is resolved per request by the server runtime. Platform-native static generation (e.g. Netlify/Cloudflare _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:

nitro.config.ts
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.

CORS is applied by the running server (h3's 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:

nitro.config.ts
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:

nitro.config.ts
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:

nitro.config.ts
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:

nitro.config.ts
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:

nitro.config.ts
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:

nitro.config.ts
import { defineConfig } from "nitro";

export default defineConfig({
  routeRules: {
    '/about': { prerender: true },
    '/dynamic/**': { prerender: false },
  }
});

ISR (Vercel)

Configure Incremental Static Regeneration for Vercel deployments:

nitro.config.ts
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

OptionTypeDescription
headersRecord<string, string>Custom response headers
redirectstring | { to: string, status?: number }Redirect to another URL (default status: 307)
proxystring | { to: string, ...proxyOptions }Proxy requests to another URL
corsboolean | CorsOptionsHandle CORS via h3's handleCors (true = permissive)
cacheobject | falseCache options (see cache guide)
swrboolean | numberShortcut for cache: { swr: true, maxAge: number } (false resets an inherited cache rule)
staticboolean | numberShortcut for static caching
basicAuth{ username, password, realm? } | falseHTTP Basic Authentication
prerenderbooleanEnable/disable prerendering
isrboolean | number | objectIncremental Static Regeneration (Vercel)

Runtime route rules

Route rules can be provided through runtimeConfig, allowing overrides via environment variables without rebuilding:

nitro.config.ts
import { defineConfig } from "nitro";

export default defineConfig({
  runtimeConfig: {
    nitro: {
      routeRules: {
        '/api/**': { headers: { 'x-env': 'production' } },
      },
    },
  },
});

Config reference

These config options control routing behavior:

OptionTypeDefaultDescription
baseURLstring"/"Base URL for all routes
apiBaseURLstring"/api"Base URL for routes in the api/ directory
apiDirstring"api"Directory name for API routes
routesDirstring"routes"Directory name for file-based routes
serverDirstring | falsefalseServer directory for scanning routes, middleware, plugins, etc.
scanDirsstring[][]Additional directories to scan for routes
routesRecord<string, string | handler>{}Route-to-handler mapping
handlersNitroEventHandler[][]Programmatic handler registration (mainly for middleware)
routeRulesRecord<string, NitroRouteConfig>{}Route rules for matching patterns
ignorestring[][]Glob patterns to ignore during file scanning