# Nitro
> Add server API routes to any Vite apps and deploy with zero configuration on your favorite hosting platform.
---
# Home
::page-hero
#title
Build [Servers]{.text-brand}
#description
Nitro builds production-ready servers that run anywhere.
Write API routes, then deploy the same codebase to Node.js, Bun, Deno, or serverless, with zero configuration.
#links
:app-hero-links
::
::stat-strip
---
# `animateFrom` is only where each counter starts spinning. It is a visual
# effect with no meaning, never a real measurement.
stats:
- value: "3.8 kB"
label: Gzipped output
animateFrom: 100
- value: "0"
label: Runtime deps
animateFrom: 35
- value: "25+"
label: Deploy targets
animateFrom: 0
- value: "~50 ms"
label: Cold start
animateFrom: 350
---
::
::feature-section
---
eyebrow: Routing
link: /docs/routing
link-label: Routing docs
points:
- Routes are compiled no runtime router ships in the bundle
- Dynamic params, wildcards and per-method files
- Route groups organize files without changing URLs
---
#title
Files in, Routes out
#description
Drop a file in `routes/` and it becomes a route. Append the HTTP method to the filename to scope it, nest folders for path params, and wrap a folder in parentheses to group routes without touching the URL.
#visual
:::route-map
---
dir: routes/
routes:
- file: hello.get.ts
method: GET
route: /hello
- file: hello.post.ts
method: POST
route: /hello
- file: api/test.ts
route: /api/test
- file: api/[org]/[repo].ts
route: /api/:org/:repo
- file: (admin)/users.ts
route: /users
---
:::
::
::feature-section
---
eyebrow: Server entry
reverse: true
link: /docs/server-entry
link-label: Server entry docs
---
#title
Bring your own framework
#description
Any framework that speaks the Web `fetch(request): Response` interface can be your server entry export it from `server.ts` and it runs for every request before routes are matched. Node-style `(req, res)` frameworks work too: name the file `server.node.ts` and Nitro adapts it.
#visual
:::tabs
::::tab{label="H3" icon="i-unjs-h3"}
```ts [server.ts]
import { H3 } from "h3";
const app = new H3();
app.get("/", () => "β‘οΈ Hello from H3!");
export default app;
```
::::
::::tab{label="Hono" icon="i-logos-hono"}
```ts [server.ts]
import { Hono } from "hono";
const app = new Hono();
app.get("/", (c) => c.text("π₯ Hello from Hono!"));
export default app;
```
::::
::::tab{label="Elysia" icon="i-skill-icons-elysia-dark"}
```ts [server.ts]
import { Elysia } from "elysia";
const app = new Elysia();
app.get("/", () => "π¦ Hello from Elysia!");
export default app.compile();
```
::::
::::tab{label="Express" icon="i-simple-icons-express"}
```ts [server.node.ts]
import Express from "express";
const app = Express();
app.use("/", (_req, res) => {
res.send("Hello from Express with Nitro!");
});
export default app;
```
::::
:::
::
::feature-section
---
eyebrow: Deploy
link: /deploy
link-label: All deploy targets
points:
- Providers are auto-detected in CI no adapter to install
- Switch targets with a single `preset` option
- Compatibility dates keep provider behavior stable over time
---
#title
One codebase, every platform
#description
The same server builds for Node.js, Deno, Bun, edge workers and serverless functions. Nitro emits the output format each host expects, so moving between them requires no code changes!
#visual
:::deploy-grid
---
more: 25+ targets
moreTo: /deploy
targets:
- name: Node.js
icon: i-simple-icons-nodedotjs
to: /deploy/runtimes/node
- name: Deno
icon: i-simple-icons-deno
to: /deploy/runtimes/deno
- name: Bun
icon: i-simple-icons-bun
to: /deploy/runtimes/bun
- name: Cloudflare
icon: i-simple-icons-cloudflare
to: /deploy/providers/cloudflare
- name: Vercel
icon: i-simple-icons-vercel
to: /deploy/providers/vercel
- name: Netlify
icon: i-simple-icons-netlify
to: /deploy/providers/netlify
- name: AWS Lambda
icon: i-simple-icons-awslambda
to: /deploy/providers/aws
- name: Azure
icon: i-simple-icons-microsoftazure
to: /deploy/providers/azure
- name: Firebase
icon: i-simple-icons-firebase
to: /deploy/providers/firebase
- name: DigitalOcean
icon: i-simple-icons-digitalocean
to: /deploy/providers/digitalocean
- name: Render
icon: i-simple-icons-render
to: /deploy/providers/render
---
:::
::
::feature-section
---
eyebrow: Output
reverse: true
link: /docs/quick-start
link-label: Build your first server
---
#title
Small enough to read
#description
A production build of a minimal Nitro server is three files with nothing to install alongside them. Dependencies are bundled and tree-shaken, so what you deploy is the code you wrote plus the little that runs it.
#visual
:::bar-chart
---
title: Production server output
data:
- label: Unminified
value: 16.2
display: 16.2 kB
- label: Minified
value: 8.8
display: 8.8 kB
- label: Minified + gzipped
value: 3.8
display: 3.8 kB
highlight: true
---
:::
::
::feature-section
---
eyebrow: Cache
link: /docs/cache
link-label: Caching docs
points:
- Stale-while-revalidate responses by default
- ETag, last-modified and 304 handling out of the box
- Concurrent requests for one key share a single invocation
---
#title
Caching that follows your storage
#description
Wrap a handler or any async function and Nitro caches its result on the same storage layer your app already uses memory in development, then Redis, Cloudflare KV, a Vercel Blob store or the filesystem in production. Same code, different backend.
#visual
```ts [routes/stars.ts]
import { defineCachedHandler } from "nitro/cache";
export default defineCachedHandler(
async () => {
const res = await fetch("https://api.github.com/repos/nitrojs/nitro");
const { stargazers_count } = await res.json();
return { stars: stargazers_count };
},
{ maxAge: 60 * 60 }
);
```
::
::feature-grid
---
eyebrow: Included
title: Everything a server needs
description: The pieces most apps reach for ship with Nitro, and every one of them behaves the same on every deployment target.
features:
- title: KV Storage
description: One key-value API over the filesystem, Redis, Cloudflare KV and more.
icon: i-lucide-hard-drive
to: /docs/storage
- title: Database
description: SQL layer powered by db0, preconfigured with SQLite.
icon: i-lucide-database
badge: Experimental
to: /docs/database
- title: Tasks
description: One-off runtime operations, run from the CLI or on a cron schedule.
icon: i-lucide-list-checks
badge: Experimental
to: /docs/tasks
- title: WebSockets
description: Cross-runtime WebSocket support built on crossws.
icon: i-lucide-radio
to: /docs/websocket
- title: Plugins
description: Hook into the server lifecycle from the plugins/ directory.
icon: i-lucide-plug
to: /docs/plugins
- title: OpenAPI
description: A spec generated from your handlers, served through Scalar or Swagger UI.
icon: i-lucide-file-json
badge: Experimental
to: /docs/openapi
- title: Assets
description: Public files served directly, server assets readable at runtime.
icon: i-lucide-image
to: /docs/assets
- title: Renderer
description: A catch-all handler for unmatched routes SSR, SPA shells or plain HTML.
icon: i-lucide-layout-template
to: /docs/renderer
- title: Lifecycle
description: Every layer a request passes through, in order, and where to intercept it.
icon: i-lucide-activity
to: /docs/lifecycle
---
::
::call-to-action{command="nitro@latest create"}
#title
Start with one command
#description
Scaffold a project, or add the Vite plugin to the app you already have.
#actions
:app-hero-links
::
::sponsors
---
# Introduction
> Nitro is a full-stack server framework, compatible with any runtime and any deployment target.
Nitro gives you a production-ready server with filesystem routing, code-splitting, and built-in support for storage, caching, and databases, all runtime-agnostic and deployable anywhere.
## What is Nitro?
Create server and API routes inside the `routes/` directory. Each file maps directly to a URL path, and Nitro handles the rest: [routing](/docs/routing), code-splitting, and optimized builds.
You can also take full control by creating a `server.ts` [server entry](/docs/server-entry). Nitroβs runtime-agnostic approach lets you use any HTTP library, such as [Elysia](https://elysiajs.com/), [H3](https://h3.dev), or [Hono](https://hono.dev).
### Performance
Nitro compiles your routes at build time, removing the need for a runtime router. Only the code required to handle each incoming request is loaded and executed. This makes it ideal for serverless hosting, with near-0ms boot time regardless of project size.
### Deploy Anywhere
Build your server into an optimized `.output/` folder compatible with Node.js, Bun, Deno, and many hosting platforms without any configuration: Cloudflare Workers, Netlify, Vercel, and more. Take advantage of platform features like ESR, ISR, and SWR without changing a single line of code.
### Server-Side Rendering
Render HTML with your favorite templating engine, or use component libraries such as React, Vue, or Svelte directly on the server. Go full universal rendering with client-side hydration. Nitro provides the foundation and a progressive approach to reach your goals.
### Storage
Nitro includes a runtime-agnostic key-value [storage layer](/docs/storage) out of the box. It uses in-memory storage by default, but you can connect more than 20 different drivers (FS, Redis, S3, etc.), attach them to different namespaces, and swap them without changing your code.
### Caching
Nitro supports [caching](/docs/cache) for both server routes and server functions, backed directly by the server storage (via the `cache` namespace).
### Database
Nitro also includes a built-in SQL [database](/docs/database). It defaults to SQLite, but you can connect to and query more than 10 databases (Postgres, MySQL, PGLite, etc.) using the same API.
### Meta-Framework Foundation
Nitro can be used as the foundation for building your own meta-framework. Popular frameworks such as Nuxt, SolidStart, and TanStack Start fully or partially leverage Nitro.
## Vite Integration
Nitro integrates seamlessly with [Vite](https://vite.dev) as a plugin. If youβre building a frontend application with Vite, adding Nitro gives you API routes, server-side rendering, and a full production server, all built together with `vite build`.
```ts [vite.config.ts]
import { defineConfig } from "vite";
import { nitro } from "nitro/vite";
export default defineConfig({
plugins: [nitro()],
});
```
With Nitro, `vite build` produces an optimized `.output/` folder containing both your frontend and backend, ready to deploy anywhere.
Ready to give it a try? Jump into the [quick start](/docs/quick-start).
---
# Quick Start
> Start with a fresh Nitro project or adopt it in your current Vite project.
## Try Nitro online
Get a taste of Nitro in your browser using our playground.
[Play with Nitro in StackBlitz](https://stackblitz.com/github/nitrojs/starter/tree/v3-vite?file=index.html,server.ts){target="_blank"}
## Create a Nitro project
The fastest way to create a Nitro application is with the `nitro create` command.
> [!NOTE]
> Make sure you have the latest LTS version of [Node.js](https://nodejs.org/en) or [Bun](https://bun.sh/) installed.
::code-group
```sh [npm]
npx --min-release-age=0 nitro@latest create
```
```sh [pnpm]
pnpm dlx --config.minimum-release-age=0 nitro@latest create
```
::
::note
The `--min-release-age=0` (npm) and `--config.minimum-release-age=0` (pnpm) flags skip any minimum release age you have configured, so you always get the latest Nitro release.
::
Follow the CLI instructions and you will be ready to start your development server. See [`nitro create`](/docs/cli#nitro-create) for all options.
## Add to a Vite project
You can add Nitro to any existing Vite project to get API routes, server-side rendering, and more.
::steps{level="3"}
### Install `nitro` and `vite`
:pm-install{name="nitro vite"}
### Add Nitro plugin to Vite
Add the Nitro plugin to your `vite.config.ts`:
```ts [vite.config.ts] {2,6}
import { defineConfig } from "vite";
import { nitro } from "nitro/vite";
export default defineConfig({
plugins: [
nitro()
],
});
```
### Configure Nitro
Create a `nitro.config.ts` to configure the server directory:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
serverDir: "./server",
});
```
The `serverDir` option tells Nitro where to look for your server routes. In this example, all routes will be inside the `server/` directory. By default `serverDir` is `false`, so no directories are scanned until you set it (or `scanDirs`).
### Create an API route
Create your first API route at `server/api/test.ts`:
::code-tree{defaultValue="server/api/test.ts"}
```ts [server/api/test.ts]
import { defineHandler } from "nitro";
export default defineHandler(() => {
return { message: "Hello Nitro!" };
});
```
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
serverDir: "./server",
});
```
```ts [vite.config.ts]
import { defineConfig } from "vite";
import { nitro } from "nitro/vite";
export default defineConfig({
plugins: [nitro()],
});
```
::
The file path maps directly to the route URL: `server/api/test.ts` becomes `/api/test`. Handlers can return strings, JSON objects, `Response` instances, or readable streams.
::tip
As an alternative to filesystem routing, you can declare routes programmatically using the `routes` config option. See [Programmatic route handlers](/docs/routing#programmatic-route-handlers) for more details.
::
### Start the development server
:pm-run{script="dev"}
Your API route is now accessible at `http://localhost:3000/api/test` :sparkles:
::
## Next steps
- Learn about dynamic routes, methods, and middleware in [Routing](/docs/routing).
- Serve HTML or a single-page app for unmatched routes with the [renderer](/docs/renderer).
- Take full control of every request with a [server entry](/docs/server-entry).
---
# Nitro Renderer
> Use a renderer to handle all unmatched routes with custom HTML or a templating system.
The renderer is a special handler in Nitro that catches all routes that don't match any specific API or route handler. It's commonly used for server-side rendering (SSR), serving single-page applications (SPAs), or creating custom HTML responses.
## Configuration
The renderer is configured using the `renderer` option in your Nitro config:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
renderer: {
template: './index.html', // Path to HTML template file
handler: './renderer.ts', // Path to custom renderer handler
static: false, // Force dynamic template processing (auto-detected when unset)
}
})
```
| Option | Type | Description |
| --- | --- | --- |
| `template` | `string` | Path to an HTML file used as the renderer template. |
| `handler` | `string` | Path to a custom renderer handler module. |
| `static` | `boolean` | When `true`, skips rendu template processing and serves the HTML as-is. Auto-detected based on template syntax when not set. |
Set `renderer: false` in the config to explicitly disable the renderer entirely (including auto-detection of `index.html`).
## HTML template
### Auto-detected `index.html`
By default, Nitro automatically looks for an `index.html` file in your project root or one of the scanned directories.
If found, Nitro will use it as the renderer template and serve it for all unmatched routes.
::code-group
```html [index.html]
My Vite + Nitro App
```
```ts [routes/api/hello.ts]
import { defineHandler } from "nitro";
export default defineHandler((event) => {
return { hello: "API" };
});
```
::
::tip
When `index.html` is detected, Nitro will automatically log in the terminal: `Using index.html as renderer template.`
::
With this setup:
- `/api/hello` β Handled by your API routes
- `/about`, `/contact`, etc. β Served with `index.html`
### Custom HTML file
You can specify a custom HTML template file using the `renderer.template` option in your Nitro configuration.
::code-group
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
renderer: {
template: './app.html'
}
})
```
```html [app.html]
Custom Template
Loading...
```
::
### Static templates
By default, Nitro auto-detects whether your HTML template contains [rendu](#hypertext-preprocessor-experimental) syntax. If it does, the template is processed dynamically on each request. If it doesn't, it's served as static HTML.
You can override this behavior with the `static` option:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
renderer: {
template: './index.html',
static: true // Force static serving, skip template processing
}
})
```
In production, static templates are inlined into the server bundle and served directly for optimal performance.
### Hypertext Preprocessor (experimental)
For dynamic templates, Nitro uses the [rendu](https://github.com/h3js/rendu) Hypertext Preprocessor, a simple and powerful way to create dynamic HTML templates with JavaScript expressions.
#### Output expressions
- `{{ expression }}`: HTML-escaped output
- `{{{ expression }}}` or `= expression ?>`: raw (unescaped) output
```html
Hello {{ $URL.pathname }}
{{{ 'raw html' }}}
```
#### Control flow
Use ` ... ?>` for JavaScript control flow:
```html
if ($METHOD === 'POST') { ?>
Form submitted!
} else { ?>
} ?>
for (const item of ['a', 'b', 'c']) { ?>
{{ item }}
} ?>
```
#### Server scripts
Use `
{{ JSON.stringify(data) }}
```
#### Streaming content
Use the `echo()` function for streaming content. It accepts strings, functions, Promises, Response objects, or ReadableStreams:
```html
```
#### Global variables
Access request context within templates:
| Variable | Description |
| --- | --- |
| `$REQUEST` | The incoming `Request` object |
| `$METHOD` | HTTP method (`GET`, `POST`, etc.) |
| `$URL` | Request `URL` object |
| `$HEADERS` | Request headers |
| `$RESPONSE` | Response configuration object |
| `$COOKIES` | Read-only object containing request cookies |
#### Built-in functions
| Function | Description |
| --- | --- |
| `htmlspecialchars(str)` | Escape HTML characters (automatically applied in `{{ }}` syntax) |
| `setCookie(name, value, options?)` | Set a cookie in the response |
| `redirect(url)` | Redirect the user to another URL |
| `echo(content)` | Stream content to the response |
```html [index.html]
Dynamic template
Hello {{ $REQUEST.url }}
Welcome, = $COOKIES["user"] || "Guest" ?>!
```
:read-more{to="https://github.com/h3js/rendu" title="Rendu Documentation"}
## Custom renderer handler
For more complex scenarios, you can create a custom renderer handler that programmatically generates responses.
The handler is a default export function that receives an H3 event object. You can access the incoming `Request` via `event.req`:
```ts [renderer.ts]
export default function renderer({ req }: { req: Request }) {
const url = new URL(req.url);
return new Response(
/* html */ `
Custom Renderer
Hello from custom renderer!
Current path: ${url.pathname}
`,
{ headers: { "content-type": "text/html; charset=utf-8" } }
);
}
```
Then, specify the renderer entry in the Nitro config:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
renderer: {
handler: './renderer.ts'
}
})
```
::note
When `renderer.handler` is set, it takes full control of rendering. The `renderer.template` option is ignored.
::
## Renderer priority
The renderer always acts as a catch-all route (`/**`) and has the **lowest priority**. This means:
1. Specific API routes are matched first (e.g., `/api/users`)
2. Specific server routes are matched next (e.g., `/about`)
3. The renderer catches everything else
```md
api/
users.ts β /api/users (matched first)
routes/
about.ts β /about (matched second)
renderer.ts β /** (catches all other routes)
```
::note
If you define your own catch-all route (`[...].ts`), it is chained with the other `/**` handlers rather than replaced: catch-all handlers run in registration order (file-based catch-all, then [server entry](/docs/server-entry), then renderer), and the first one to return a response wins. The renderer only handles requests your catch-all leaves unanswered.
::
:read-more{to="/docs/lifecycle" title="Lifecycle"}
## Vite integration
When using Nitro with Vite, the renderer integrates with Vite's build pipeline and dev server.
### Development mode
In development, the renderer template is read from disk on each request, so changes to `index.html` are reflected immediately without restarting the server. Vite's `transformIndexHtml` hook is applied to inject HMR client scripts and other dev-time transforms.
### SSR with ``
When using Vite environments with an `ssr` service, you can add an `` comment to your `index.html`. Nitro will replace it with the output from your SSR entry during rendering:
```html [index.html]
SSR App
```
### Production build
During production builds, Vite processes the `index.html` through its build pipeline (resolving scripts, CSS, and other assets), then Nitro inlines the transformed HTML into the server bundle.
## Use cases
### Single-page application (SPA)
Serve your SPA's `index.html` for all routes to enable client-side routing:
> [!TIP]
> This is the default behavior of Nitro when used with Vite.
---
# 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](https://h3.dev/guide/api/h3event#h3event-properties)) and returns a response.
::code-group
```ts [defineHandler]
import { defineHandler } from "nitro";
export default defineHandler((event) => {
return "world";
});
```
```ts [Single function]
import type { H3Event } from "nitro";
export default (event: H3Event) => {
return "world";
};
```
::
## Filesystem routing
Files are automatically mapped to [h3 routes](https://h3.dev/guide/basics/routing). Defining a route is as simple as creating a file inside the `api/` or `routes/` directory of your [`serverDir`](/docs/configuration).
::note
Filesystem routing requires setting [`serverDir`](/docs/configuration) (or `scanDirs`) in your config; no directories are scanned by default.
::
Each file defines a single handler, and you can [append the HTTP method](#specific-request-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.
```txt
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:
```txt
routes/
api/
(admin)/
users.ts <-- /api/users
reports.ts <-- /api/reports
(public)/
index.ts <-- /api
package.json
```
> [!NOTE]
> 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:
```ts [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 `[]` syntax where `` is the name of the param. The param will be available on the `event.context.params` object.
```ts [routes/hello/[name\\].ts]
import { defineHandler } from "nitro";
export default defineHandler((event) => {
const { name } = event.context.params;
return `Hello ${name}!`;
});
```
Calling `/hello/nitro` returns:
```txt [Response]
Hello nitro!
```
#### Multiple params
You can define multiple params in a route using `[]/[]` syntax where each param is a folder. You **cannot** define multiple params in a single filename or folder.
```ts [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 `[...]` syntax. This will include the `/` in the param.
```ts [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:
```txt [Response]
Hello nitro/is/hot!
```
A catch-all param also matches the base path: `/hello` and `/hello/` are handled by `routes/hello/[...name].ts` too, with `name` being `undefined` (for `/hello`) or `""` (for `/hello/`). A sibling `routes/hello/index.ts` takes precedence for the base path. A root `routes/[...name].ts` also matches `/`, with `name` being `""`.
When catch-alls are nested, the innermost one handles its own base path. With `routes/[...slug].ts` and `routes/docs/[...path].ts`, `/docs` is handled by `docs/[...path].ts` (with `path` undefined), not by the root catch-all.
### 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`, `query`, `connect`, `trace`.
If a `.head` route matches, it handles `HEAD` requests. Otherwise they are handled by the route that would handle a `GET` request (including a `.get` route over a method-agnostic one, and `GET`-scoped middleware). The response body is always dropped for `HEAD` requests, including those handled by a `.head` route.
::note
`query` matches the [`QUERY` method](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/), a safe and idempotent alternative to `GET` that carries a request body. Support across proxies, CDNs and deployment platforms is still uneven, so keep a `GET` or `POST` fallback for public APIs.
::
When a request path matches a route only for other methods, Nitro responds with `405 Method Not Allowed` and an `Allow` header listing them. If a catch-all route (such as `routes/[...].ts`, a renderer or a server entry) matches the request, it handles it instead.
::code-group
```ts [GET]
// 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!`;
});
```
```ts [POST]
// 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`.
```ts [routes/[...\\].ts]
import { defineHandler } from "nitro";
export default defineHandler((event) => {
return `Hello ${event.url.pathname}!`;
});
```
::note
With an unnamed catch-all (`[...].ts`), the matched wildcard segments are available as `event.context.params[0]`.
::
The [server entry](/docs/server-entry) and [renderer](/docs/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):
```txt
routes/
env/
index.dev.ts <-- /env (dev only)
index.get.prod.ts <-- /env (GET, prod only)
```
> [!TIP]
> To target multiple environments, or a preset name as the environment, register the route programmatically via the [`routes`](#routes-config) config.
### Reserved suffixes
Nitro reserves trailing lowercase suffixes in the names of scanned files (`routes/`, `api/`, `middleware/`, `plugins/`, `tasks/` and `modules/`) for its own conventions. Known file extensions are kept as part of the route path:
```txt
routes/
sitemap.xml.ts <-- /sitemap.xml
docs/[slug].md.ts <-- /docs/:slug.md
hello.world.ts <-- error: unknown suffix `.world`
```
Suffixes with digits, uppercase letters or other characters (e.g. `v1.2.ts`) are always kept.
To keep a custom suffix, add it to the [`scanSuffixes`](/config/directories#scansuffixes) config:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
scanSuffixes: ["world"], // routes/hello.world.ts -> /hello.world
});
```
### Ignoring files
Test files (`*.test.ts`, `*.spec.ts`) and type declarations (`*.d.ts`) are never registered, so they can live next to your handlers.
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.
```ts [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.
> [!IMPORTANT]
> This feature is currently experimental.
```ts [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");
```
::read-more{to="https://swagger.io/specification/v3/"}
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:
```ts [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:
| 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:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
handlers: [
{
route: "/api/**",
handler: "./server/utils/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](/docs/lifecycle) for where middleware fit in the request pipeline.
::note
Middleware can modify the request before it is processed, not after.
::
Middleware are auto-registered from the `middleware/` directory.
```md
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.
```ts [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](#route-scoped-middleware).
::warning
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.
```md
middleware/
auth.ts <-- First
logger.ts <-- Second
... <-- Third
```
Prefix middleware with a number to control their execution order.
```md
middleware/
1.logger.ts <-- First
2.auth.ts <-- Second
3.... <-- Third
```
::note
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:
```ts [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" };
}
});
```
::warning
Middleware are not a security boundary. Global and route-scoped middleware are **implicit** guards: they protect the URLs they happen to match, not the handlers themselves. A path check like `startsWith('/auth')` also matches `/authors`, and any route added outside the pattern is silently left unprotected. Use them for cross-cutting concerns (logging, context, defense in depth), and enforce access control explicitly in the handler. See [Authentication](#authentication).
::
### Route-scoped middleware
You can register middleware for specific route patterns using the [`handlers`](#handlers-config) config with the `middleware` option and a specific `route`:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
handlers: [
{
route: "/api/**",
handler: "./server/utils/api-auth.ts",
middleware: true,
},
],
});
```
Unlike global middleware (auto-registered from the `middleware/` directory, matching `/**`), route-scoped middleware only run for requests matching the specified pattern.
Keep route-scoped handler files outside `middleware/` (as in the example above) so directory scanning does not also register them globally.
## Error handling
You can use the [utilities available in H3](https://h3.dev/guide/basics/error) 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](/docs/lifecycle#error-handling).
## 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`](/config/build#inlinedynamicimports) to bundle everything into a single file.
## Route rules
Route rules let you attach behavior (redirects, proxying, caching, CORS, custom headers) to route patterns directly from your configuration, without writing handler code.
Each rule maps a route pattern (using [rou3](https://github.com/h3js/rou3) syntax) to a set of options, built on [h3 route rules](https://h3.dev/guide/rules). When the `cache` option is set, matching handlers are automatically wrapped with `defineCachedHandler`. See the [cache guide](/docs/cache) to learn more.
Set route rules with the `routeRules` config option:
```ts [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/**' },
}
});
```
::warning
Route rules are not a security boundary. Like global middleware, they are **implicit** guards: they apply to whatever request URLs match a pattern, not to the handlers they are meant to protect. A rule can be overridden by a more specific one, and a handler stays unprotected when it is reachable from a path the pattern does not match (a new route, a renamed file, a `proxy` target, a different `baseURL`). Some rules are also applied by the platform edge and others only by the server runtime. Enforce access control explicitly where the protected code runs. See [Authentication](#authentication).
::
::note
Netlify and Cloudflare `_headers` and `_redirects` only support static segments, `:name` params and a single `*` splat (as the last segment in `_redirects`). Rules with other patterns, such as `/num/:id(\d+)` or `/a/:id?`, are left out of them (with a build warning) and only the runtime applies them, which means static assets don't get their headers. A `redirect` rule that also matches the requests of a skipped one, such as `/num/**` next to `/num/:id(\d+)`, is left out of `_redirects` too, so that it cannot take precedence over it.
::
### 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:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
'/api/cached/**': { swr: true },
'/api/cached/no-cache': { cache: false, swr: false },
'/api/**': { cors: true },
'/api/internal/**': { cors: 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:
```ts [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/** get this header
'POST /api/**': { headers: { 'x-write': 'true' } },
// Cache GET requests to /feed only
'GET /feed': { swr: 600 },
}
});
```
::note
Method matching is resolved per request by the server runtime. Vercel and EdgeOne `config.json` routes are scoped to the same methods. Netlify and Cloudflare `_headers` and `_redirects` cannot match by method, so method-scoped rules are left out of them (with a build warning) and only the runtime applies them, which means static assets don't get their headers. A method-agnostic `redirect` rule that also matches the requests of a skipped method-scoped one, such as `/old/**` next to `GET /old/**`, is left out of `_redirects` too, so that it cannot take precedence over it. Prefer method-agnostic keys for `headers` and `redirect` rules that a platform should apply at its edge.
::
### Headers
Set custom response headers for matching routes:
```ts [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.
> [!NOTE]
> CORS is applied by the running server (h3's [`handleCors`](https://h3.dev/utils/security#handlecorsevent-options)), and not from the static/CDN config.
> On platforms that serve public and prerendered assets straight from the CDN, those responses only get the platform's default headers (e.g. Vercel sends `access-control-allow-origin: *`, Netlify sends none). To set CORS headers there, also add them with `headers`, which are written to the platform config:
>
> ```ts
> '/**': { cors: true, headers: { 'access-control-allow-origin': '*' } },
> ```
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:
```ts [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:
```ts [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/**' },
}
});
```
The redirect target is used as written. With a non-root [`baseURL`](/config/routing#baseurl), route rule keys are matched relative to it, but relative targets are not prefixed, so include the `baseURL` in the target when needed.
### Proxy
Proxy requests to another URL. Supports both internal and external targets:
```ts [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...
},
},
}
});
```
### Authentication
There is no `auth` route rule. Route rules and middleware match request URLs, so they can only guard routes [implicitly](#route-rules). Instead, enforce access control **explicitly** in the code you want to protect, using composable h3 utilities such as [`requireBasicAuth`](https://h3.dev/utils/security#requirebasicauthevent-opts).
Wrap the check in a small reusable utility, reading the credentials from environment variables rather than hardcoding them:
```ts [utils/auth.ts]
import type { H3Event } from "nitro";
import { requireBasicAuth } from "nitro/h3";
export async function requireAdmin(event: H3Event) {
await requireBasicAuth(event, {
username: process.env.ADMIN_USERNAME,
password: process.env.ADMIN_PASSWORD!,
realm: "Admin Area",
});
return event.context.basicAuth!.username;
}
```
Then call it at the top of each handler that needs it. The request fails with `401` before any protected logic runs (or with `500` if `ADMIN_PASSWORD` is not set):
```ts [routes/admin/index.ts]
import { defineHandler } from "nitro";
import { requireAdmin } from "../../utils/auth.ts";
export default defineHandler(async (event) => {
const username = await requireAdmin(event);
return `Hello, ${username}!`;
});
```
The guard is visible next to the code it protects and keeps working wherever the handler is mounted or however it is reached. The same utility can be reused by any handler, or called only on the code paths that need it.
To attach the guard declaratively, pass it to the handler's `middleware` option. Like the call above, it stays bound to the handler rather than to a URL pattern. Middleware should not return a value, so wrap the utility:
```ts [routes/admin/settings.ts]
import { defineHandler } from "nitro";
import { requireAdmin } from "../../utils/auth.ts";
export default defineHandler({
middleware: [
async (event) => {
await requireAdmin(event);
},
],
handler: (event) => `Settings for ${event.context.basicAuth?.username}`,
});
```
h3 also provides a ready-made [`basicAuth`](https://h3.dev/utils/security#basicauthopts) middleware factory (`middleware: [basicAuth({ ... })]`) for the same purpose.
### Caching (SWR / Static)
Control caching behavior with `cache`, `swr`, or `static` options:
```ts [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 },
}
});
```
::tip
`swr: true` is a shortcut for `cache: { swr: true }` and `swr: ` is a shortcut for `cache: { swr: true, maxAge: }`.
::
### Prerender
Mark routes for prerendering at build time:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
'/about': { prerender: true },
'/dynamic/**': { prerender: false },
}
});
```
Prerendered routes are generated once at build time and served as static files. They are not revalidated at runtime.
::warning
Do not combine `prerender` and `isr` on the same route. Prerendering takes precedence, so `isr` is ignored and the page will not be regenerated after deployment. Use `isr` alone when you need incremental regeneration on supported platforms.
::
### ISR (Vercel)
Configure Incremental Static Regeneration for Vercel deployments:
```ts [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,
},
},
}
});
```
::note
ISR generates pages on demand and revalidates them according to the expiration you set. It is an alternative to build-time `prerender`, not something you combine with it on the same route.
::
### Route rules reference
| Option | Type | Description |
|--------|------|-------------|
| `headers` | `Record` | 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`](https://h3.dev/utils/security#handlecorsevent-options) (`true` = permissive) |
| `cache` | `object \| false` | Cache options (see [cache guide](/docs/cache)) |
| `swr` | `boolean \| number` | Shortcut for `cache: { swr: true, maxAge: number }` (`false` resets an inherited cache rule) |
| `static` | `boolean \| number` | Shortcut for static caching |
| `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:
```ts [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:
| 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` | `{}` | Route-to-handler mapping |
| `handlers` | `NitroEventHandler[]` | `[]` | Programmatic handler registration (mainly for middleware) |
| `routeRules` | `Record` | `{}` | Route rules for matching patterns |
| `ignore` | `string[]` | `[]` | Glob patterns to ignore during file scanning |
---
# Nitro Server Entry
> Use a server entry to handle every request that no route matched.
The server entry is a special handler that Nitro registers as a catch-all (`/**`) route. Specific routes always win, so the server entry only runs for requests none of them matched, right before the [renderer](/docs/renderer). It is commonly used to mount another framework inside Nitro, or to implement custom routing for everything the filesystem routes don't claim.
::warning
The server entry is a fallback, not a global middleware: it does not run for requests a route handled. For cross-cutting concerns that must apply to **every** request (authentication, logging, request preprocessing), use [middleware](/docs/routing#middleware) instead.
::
## Auto-detected `server.ts`
By default, Nitro automatically looks for a `server.ts` (or `.js`, `.mjs`, `.mts`, `.tsx`, `.jsx`) file in your `serverDir` (if set) or your project root directory.
If found, Nitro will use it as the server entry and run it for all incoming requests.
::code-group
```ts [server.ts]
export default {
async fetch(req: Request) {
const url = new URL(req.url);
// Handle specific routes
if (url.pathname === "/health") {
return new Response("OK", {
status: 200,
headers: { "content-type": "text/plain" }
});
}
// Add custom headers to all requests
// Return nothing to continue to the next handler
}
}
```
```ts [routes/api/hello.ts]
import { defineHandler } from "nitro";
export default defineHandler((event) => {
return { hello: "API" };
});
```
::
::tip
When `server.ts` is detected, Nitro will log in the terminal: ``Detected `server.ts` as server entry.``
::
With this setup:
- `/health` β Handled by server entry (returns a response)
- `/api/hello` β Handled by the API route handler directly
- `/about`, etc. β Server entry runs first, then continues to the renderer if no response is returned
## Framework compatibility
The server entry is a great way to integrate with other frameworks. Any framework that exposes a standard Web `fetch(request: Request): Response` interface can be used as a server entry.
### Web-compatible frameworks
Frameworks that implement the Web `fetch` API work directly with `server.ts`:
::tabs
::tabs-item{label="H3" icon="i-undocs-h3"}
```ts [server.ts]
import { H3 } from "h3";
const app = new H3()
app.get("/", () => "β‘οΈ Hello from H3!");
export default app;
```
::
::tabs-item{label="Hono" icon="i-undocs-hono"}
```ts [server.ts]
import { Hono } from "hono";
const app = new Hono();
app.get("/", (c) => c.text("π₯ Hello from Hono!"));
export default app;
```
::
::tabs-item{label="Elysia" icon="i-undocs-elysia"}
```ts [server.ts]
import { Elysia } from "elysia";
const app = new Elysia();
app.get("/", () => "π¦ Hello from Elysia!");
export default app.compile();
```
::
::
### Node.js frameworks
For Node.js frameworks that use `(req, res)` style handlers (like [Express](https://expressjs.com/) or [Fastify](https://fastify.dev/)), name your server entry file `server.node.ts` instead of `server.ts`. Nitro will automatically detect the `.node.` suffix and convert the Node.js handler to a web-compatible fetch handler using [`srvx`](https://srvx.h3.dev/).
::tabs
::tabs-item{label="Express"}
```ts [server.node.ts]
import Express from "express";
const app = Express();
app.use("/", (_req, res) => {
res.send("Hello from Express with Nitro!");
});
export default app;
```
::
::tabs-item{label="Fastify"}
```ts [server.node.ts]
import Fastify from "fastify";
const app = Fastify();
app.get("/", () => "Hello, Fastify with Nitro!");
await app.ready();
export default app.routing;
```
::
::
## Server options
When the server entry's default export is a plain object, the following server options are passed to the [srvx](https://srvx.h3.dev/) server started by the [Node.js](/deploy/runtimes/node), [Bun](/deploy/runtimes/bun) and [Deno](/deploy/runtimes/deno) presets: `port`, `hostname`, `reusePort`, `protocol`, `tls`, `silent`, `gracefulShutdown`, `maxRequestBodySize`, `trustProxy` and runtime specific settings (`node`, `bun`, `deno`).
Use the `defineServerEntry` helper for typed options:
```ts [server.ts]
import { defineServerEntry } from "nitro";
export default defineServerEntry({
fetch(req) {
return new Response("Hello from server entry!");
},
port: 8080,
maxRequestBodySize: 1024 * 1024,
});
```
::read-more{to="https://srvx.h3.dev/guide/options" title="srvx server options"}
::
::note
- `NITRO_PORT`/`PORT`, `NITRO_HOST`/`HOST` and `NITRO_SSL_CERT`/`NITRO_SSL_KEY` environment variables take precedence over the `port`, `hostname` and `tls` options, so the server stays configurable at runtime.
- To listen on a UNIX socket, set the `NITRO_UNIX_SOCKET` environment variable, or `node: { path }`, `bun: { unix }` or `deno: { path }` in the server entry.
- During development (`nitro dev`), options are applied to the dev worker server, except listener options (`port`, `hostname`, `protocol`, `tls`, `silent`, `gracefulShutdown`) which are controlled by the dev server and CLI (`--port`, `--host`). The Vite dev server applies none of them.
- Other srvx options (`middleware`, `plugins`, `error` and `manual`) are ignored. They change how requests are handled, but would only apply to these presets and not to serverless deployments or `nitroApp.fetch`. Use [Nitro middleware](/docs/routing#middleware) and [plugins](/docs/plugins) instead.
- Options are only read from plain object exports (not from framework instances like `export default app`) and never from Node.js format entries (`server.node.ts`).
::
## Configuration
### Custom server entry file
You can specify a custom server entry file using the `serverEntry` option in your Nitro configuration:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
serverEntry: "./nitro.server.ts"
})
```
You can also provide an object with `handler` and `format` options:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
serverEntry: {
handler: "./server.ts",
format: "node" // "web" (default) or "node"
}
})
```
### Handler format
The `format` option controls how Nitro treats the default export of your server entry:
- **`"web"`** (default): Expects a Web-compatible handler with a `fetch(request: Request): Response` method.
- **`"node"`**: Expects a Node.js-style `(req, res)` handler. Nitro automatically converts it to a web-compatible handler.
When auto-detecting, the format is determined by the filename: `server.node.ts` uses `"node"` format, while `server.ts` uses `"web"` format.
### Disabling server entry
Set `serverEntry` to `false` to disable auto-detection and prevent Nitro from using any server entry:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
serverEntry: false
})
```
## Using an event handler
Instead of a Web `fetch` handler, you can export an event handler made with `defineHandler` for better type inference and access to the H3 event object:
```ts [server.ts]
import { defineHandler, HTTPError } from "nitro";
export default defineHandler((event) => {
// Runs only for requests no route matched
if (event.url.pathname.startsWith("/api/")) {
throw new HTTPError("Unknown API endpoint", { status: 404 });
}
// Add context for the renderer
event.context.requestId = crypto.randomUUID();
// Return nothing to hand the request over to the renderer
});
```
::important
Returning `undefined` (or nothing) hands the request to the [renderer](/docs/renderer). Without a renderer, Nitro answers with an empty `200` response. Returning a value ends the request there.
::
## Startup logic
The server entry is imported eagerly, so its top-level code runs **once when the server starts**, not on the first request.
A `fetch` handler is optional. A server entry that only runs startup logic or sets [server options](#server-options) can leave it out, or have no default export at all. Requests no route matched then go to the renderer, or get a `404` response. This does not apply to [Node.js format](#nodejs-frameworks) entries (`server.node.ts`), which must default export a `(req, res)` handler.
```ts [server.ts]
import { defineServerEntry } from "nitro";
console.log("Server is starting...");
export default defineServerEntry({
port: 8080,
});
```
Keep in mind:
- Top-level code runs **before** [runtime plugins](/docs/plugins), so hooks registered by plugins are not available yet. Do not call `useNitroApp()` at the top level; call it inside the handler instead.
- A top-level `await` delays startup: the server does not accept requests until the server entry has finished evaluating.
- On serverless and edge presets, "startup" means every cold start of a new instance. Some runtimes, like Cloudflare Workers, don't allow I/O (such as `fetch`) in the global scope.
- In development, top-level code runs again whenever the server entry is reloaded.
## Request lifecycle
The server entry is registered as a catch-all (`/**`) route handler. When a specific route (like `/api/hello`) matches a request, that route handler takes priority. For requests that don't match any specific route, the server entry runs before the renderer:
```md
1. Server hook: `request`
2. Route rules (headers, redirects, etc.)
3. Global middleware (static assets first, then middleware/)
4. Route-scoped middleware (handlers config)
5. Route matching:
a. Specific routes (routes/) β if matched, handles the request
b. Server entry β runs for unmatched routes
c. Renderer (renderer.ts or index.html)
```
When both a server entry and a [renderer](/docs/renderer) exist, they are chained: the server entry runs first, and if it doesn't return a response, the renderer handles the request.
:read-more{to="/docs/lifecycle" title="Lifecycle"}
## Development mode
During development, Nitro watches for changes to your server entry file. When the file is created, modified, or deleted, the dev server automatically reloads to pick up the changes.
## Best practices
- Use the server entry as the fallback for requests no route matched, or to mount another framework
- Use [middleware](/docs/routing#middleware) for concerns that must apply to every request, matched routes included
- Return `undefined` to continue to the renderer; return a value to end the request
- Keep the server entry lightweight; it runs for every unmatched request
- Use [runtime plugins](/docs/plugins) for one-time initialization logic
- Don't use the server entry for route-specific logic; [route handlers](/docs/routing) are more performant
---
# Cache
> Nitro provides a caching system powered by [ocache](https://ocache.unjs.io/), with pluggable [cache storage](#cache-storage) (in-memory, filesystem or the [KV storage layer](/docs/storage)).
Caching lets you store the result of expensive operations (rendered responses, upstream API calls, heavy computations) and serve it again without redoing the work. Nitro offers three ways in:
- [Cached handlers](#cached-handlers): cache the full response of an event handler.
- [Cached functions](#cached-functions): cache the result of any function and reuse it across handlers.
- [Route rules](#using-route-rules): enable caching for route patterns from your config, without touching handler code.
## Cached handlers
To cache an event handler, use the `defineCachedHandler` method.
It works like `defineHandler` but with a second parameter for the [cache options](#options).
```ts [routes/cached.ts]
import { defineCachedHandler } from "nitro/cache";
export default defineCachedHandler((event) => {
return "I am cached for an hour";
}, { maxAge: 60 * 60 });
```
With this example, the response will be cached for 1 hour. Once the cache expires, the next request waits for the handler to resolve a fresh value before responding. If you prefer to immediately serve the stale response while it is revalidated in the background, set `swr: true` (see [SWR behavior](#swr-behavior)).
See the [options](#options) section for more details about the available options.
::important
`maxAge` defaults to **1 second**. Always set it explicitly for anything you actually want cached.
::
### What the handler can see
For cacheable requests, **a handler receives exactly the request data that the cache key covers**. Everything else is removed before the handler runs, so a handler cannot render output from a value the key does not distinguish and have it stored under a shared key:
- **Request headers** are stripped unless listed in [`varies`](#handler-only-options). This includes `if-none-match` / `if-modified-since` (Nitro answers conditional requests itself) and tracing headers such as `traceparent` or `x-request-id`.
- **Query parameters** are stripped unless listed in [`allowQuery`](#handler-only-options). By default `/page`, `/page?a=1` and `/page?utm=x` all share one entry, and `url.searchParams` is empty inside the handler.
- **Cookies** are stripped unless listed in [`allowCookies`](#handler-only-options), and `set-cookie` is removed from every cached response.
- **`authorization` / `proxy-authorization`** are stripped unless [`allowAuthorization`](#handler-only-options) is set.
- **`host`** is the one header that is rewritten rather than removed: it carries the host of the resolved request origin, which is part of the key. A handler serving several hostnames therefore gets one entry per host.
Use [`shouldBypassCache`](#shared-options) for requests that must reach the handler untouched. A bypassed request keeps its headers, cookies, query string and body, and its response is returned without being stored.
::read-more{to="https://ocache.unjs.io/docs/handler#headers-the-handler-cant-see"}
ocache: headers the handler can't see.
::
### Automatic HTTP headers
When using `defineCachedHandler`, Nitro automatically manages HTTP cache headers on cached responses:
- **`etag`**: a weak ETag (`W/"..."`) is generated from the response body hash if not already set by the handler.
- **`cache-control`**: synthesized from the lifetime actually enforced for the entry, unless the handler already set one (see the table below).
- **`vary`**: every name in `varies` is merged into the response `Vary`, plus `Cookie` when `allowCookies` is used. `allowQuery` adds nothing, because query parameters already live in the URL.
- **`x-cache`**: a CDN-style cache status header (`HIT`, `STALE`, `REVALIDATED` or `MISS`). Customizable with the [`cacheStatusHeader`](#handler-only-options) option.
| Options | Synthesized `cache-control` |
| ------- | --------------------------- |
| `{ maxAge: 60 }` | `max-age=60` |
| `{ maxAge: 60, swr: true }` | `max-age=60, s-maxage=60` |
| `{ maxAge: 60, swr: true, staleMaxAge: 600 }` | `max-age=60, s-maxage=60, stale-while-revalidate=600` |
`stale-while-revalidate` is only advertised when `staleMaxAge` is set, since the directive requires a number of seconds. With `swr: true` and no `staleMaxAge` the stale window is unlimited, so downstream caches simply revalidate after `max-age` while Nitro answers from its stale copy.
To keep caching server-side only without advertising `cache-control` to clients and CDNs, set [`sendCacheControl: false`](#handler-only-options).
::note
**`last-modified` is never generated.** The time an entry was filled is not the time the content changed, so set the header yourself when the handler knows the real modification time. A handler-set `cache-control` is likewise never overwritten.
::
### Conditional requests (304 Not Modified)
Cached handlers automatically support conditional requests. When a client sends `if-none-match` matching the cached `etag`, or `if-modified-since` at or after a `last-modified` the handler set, Nitro returns a `304 Not Modified` response without a body. `if-none-match` wins when both are present.
The request validators are captured before the request is narrowed, so **the handler never sees them** and cannot do its own conditional handling.
### Cacheable requests
Only `GET` and `HEAD` requests are cached. All other HTTP methods (`POST`, `PUT`, `DELETE`, etc.) automatically bypass the cache and call the handler directly, as do requests carrying a `Range` header.
`GET` and `HEAD` are cached under **separate entries**, since a `HEAD` response carries no body. The method stays part of the key even with a custom `getKey`.
### Request deduplication
When multiple concurrent requests hit the same cache key while the cache is being resolved, only one invocation of the handler runs. All concurrent requests wait for and share the same result.
A shared resolution is bounded by [`maxResolveTime`](#shared-options) (30 seconds by default). On timeout every waiter rejects with a `TimeoutError`, the entry being refreshed is evicted, and the key is free again. Handlers additionally receive that deadline as `event.req.signal`, so you can forward it to upstream `fetch` calls.
## Cached functions
You can also cache a function using `defineCachedFunction`. This is useful for caching the result of a function that is not an event handler, but is part of one, and reusing it in multiple handlers.
For example, you might want to cache the result of an API call for one hour:
```ts [routes/api/stars/[...repo\\].ts]
import { defineCachedFunction } from "nitro/cache";
import { defineHandler, type H3Event } from "nitro";
export default defineHandler(async (event) => {
const { repo } = event.context.params;
const stars = await cachedGHStars(repo).catch(() => 0)
return { repo, stars }
});
const cachedGHStars = defineCachedFunction(async (repo: string) => {
const data = await fetch(`https://api.github.com/repos/${repo}`).then(res => res.json());
return data.stargazers_count;
}, {
maxAge: 60 * 60,
name: "ghStars",
getKey: (repo: string) => repo
});
```
The stars will be cached using the [cache storage](#cache-storage) (in-memory by default) under a [cache key](#cache-keys) derived from the group, name and `getKey` result, with `value` being the number of stars.
```json
{"expires":1677851092249,"value":43991,"mtime":1677847492540,"integrity":"ZUHcsxCWEH"}
```
::important
Because the cached data is serialized to JSON, it is important that the cached function does not return anything that cannot be serialized, such as Symbols, Maps, Sets...
A value that **is** bytes (a `Uint8Array`, `ArrayBuffer`, or any typed array) is handled without a hook and always comes back as a `Uint8Array`. How it is *stored* depends on the backend: one that declares `binary` (Nitro's `memory` and `fs` [cache drivers](#cache-storage)) keeps the bytes as-is, while a serializing backend (such as the `kv` driver) stores base64. Bytes nested inside an object never take this path, use [`serialize`/`transform`](#function-only-options) for those shapes.
::
::note
If you are using edge workers to host your application, you should follow the instructions below.
In edge workers, the instance is destroyed after each request. Nitro automatically uses `event.waitUntil` to keep the instance alive while the cache is being updated while the response is sent to the client.
To ensure that your cached functions work as expected in edge workers, **you should always pass the `event` as the first argument to the function using `defineCachedFunction`.**
```ts [routes/api/stars/[...repo\\].ts] {5,10,17}
import { defineCachedFunction } from "nitro/cache";
import { defineHandler, type H3Event } from "nitro";
export default defineHandler(async (event) => {
const { repo } = event.context.params;
const stars = await cachedGHStars(event, repo).catch(() => 0)
return { repo, stars }
});
const cachedGHStars = defineCachedFunction(async (event: H3Event, repo: string) => {
const data = await fetch(`https://api.github.com/repos/${repo}`).then(res => res.json());
return data.stargazers_count;
}, {
maxAge: 60 * 60,
name: "ghStars",
getKey: (event: H3Event, repo: string) => repo
});
```
This way, the function will be able to keep the instance alive while the cache is being updated without slowing down the response to the client.
On platforms where the request carries no `waitUntil`, pass the platform hook through the [`waitUntil` option](#shared-options) instead.
::
## Options
The `defineCachedHandler` and `defineCachedFunction` functions accept the following options:
### Shared options
These options are available for both `defineCachedHandler` and `defineCachedFunction`:
::field-group
::field{name="base" type="string | string[]"}
Storage base (prefix) used for cache keys, usually matching a storage mount point. :br
Defaults to `/cache` (stored under the `cache:` prefix). When an array is provided, reads try each base in order and writes go to all of them (multi-tier caching).
::
::field{name="name" type="string"}
Guessed from the function name if not provided, and falls back to `anon_` (computed from the function code) otherwise.
::
::field{name="group" type="string"}
Defaults to `'nitro/handlers'` for handlers and `'nitro/functions'` for functions.
::
::field{name="getKey()" type="(...args) => string"}
A function that accepts the same arguments as the original function and returns a cache key (`String`). :br
If not provided, a built-in hash function will be used to generate a key based on the function arguments. For cached handlers, the key is derived from the request origin, path and method. :br :br
A custom `getKey` replaces the generated key but **not** the request narrowing: `allowQuery`, `allowCookies` and `varies` still decide what the handler sees.
::
::field{name="integrity" type="string"}
A value that invalidates the cache when changed. :br
By default, it is computed from **function code and options**, so changing either silently ignores older entries instead of serving them.
::
::field{name="maxAge" type="number"}
Maximum age that cache is valid, in seconds. :br
Defaults to `1` (second). An explicit `0` disables caching for that handler or function; an omitted or `undefined` value falls back to the default.
::
::field{name="staleMaxAge" type="number"}
Maximum number of seconds a stale value can still be served after `maxAge` expires, while revalidation happens in the background. Only applies when `swr` is enabled. :br
When set to `0`, stale values are never served (equivalent to disabling `swr`). When unset, stale values can be served without a time limit and **no storage TTL is written**, so entries live until the backend evicts them.
::
::field{name="swr" type="boolean"}
Enable `stale-while-revalidate` behavior to serve a stale cached value while asynchronously revalidating it. :br
When enabled, expired cached values are returned immediately while revalidation happens in the background. When disabled, the caller waits while the value is revalidated in the foreground. :br
Defaults to `false`. See [SWR behavior](#swr-behavior).
::
::field{name="getMaxAge()" type="(entry: CacheEntry) => number | { maxAge?, staleMaxAge? }"}
Derive the cache lifetime per entry from the resolved value, overriding the static `maxAge` / `staleMaxAge` options for that entry. Return a number of seconds (shorthand for `maxAge`) or an object to also override `staleMaxAge`. :br
Useful for values that carry their own expiry, such as access tokens: `getMaxAge: (entry) => entry.value.expires_in`. A resolved value `<= 0` disables caching for that entry. For handlers, `entry.value` is the `Response` and the synthesized `cache-control` follows the returned lifetime.
::
::field{name="maxResolveTime" type="number"}
Deadline for one shared resolution, in seconds. Defaults to `30`. :br
On timeout every waiting caller rejects with a `TimeoutError` and the entry being refreshed is evicted. Set `Infinity` or `0` to disable it, and raise it for legitimately slow resolvers.
::
::field{name="storage" type="StorageInterface | () => StorageInterface"}
Override the backend used for this handler or function. :br
Defaults to Nitro's [cache storage](#cache-storage), configured with the `cache.driver` option.
::
::field{name="waitUntil()" type="(promise: Promise) => void"}
Hand background work (cache writes, SWR refreshes, evictions) to the host runtime. :br
Takes precedence over `event.req.waitUntil`. Only needed on serverless platforms where the request does not carry one.
::
::field{name="shouldInvalidateCache()" type="(...args) => boolean | Promise"}
A function that returns a `boolean` to invalidate the current cache and create a new one.
::
::field{name="shouldBypassCache()" type="(...args) => boolean | Promise"}
A function that returns a `boolean` to bypass the current cache without invalidating the existing entry. :br
For handlers, a bypassed request reaches the handler **unchanged** (credentials, cookies and full query string included) and its response is neither stored nor given cache headers.
::
::field{name="onError()" type="(error: unknown) => void"}
A custom error handler called when the cached function throws. :br
By default, errors are logged to the console and captured by the Nitro error handler.
::
::
### Handler-only options
These options are only available for `defineCachedHandler`:
::field-group
::field{name="headersOnly" type="boolean"}
When `true`, skip full response caching and only handle conditional request headers (`if-none-match`, `if-modified-since`) for `304 Not Modified` responses. The handler is called on every request and its own `etag` / `last-modified` are the conditions, so it must set them itself. No `x-cache` header is emitted in this mode.
::
::field{name="varies" type="string[]"}
An array of request header names to vary the cache key on. Headers listed here are preserved on the request during cache resolution and included in the cache key, making the cache unique per combination of header values. They are also merged into the response's `Vary` header. :br :br
Headers **not** listed in `varies` are stripped from the request before calling the handler to ensure consistent cache hits. :br :br
A response whose own `Vary` names a header outside the key is returned but **not stored**, so keep the two lists in sync. :br :br
Multi-tenant setups no longer need `['host']` here: the request origin is already part of the generated key. Add `x-forwarded-host` only if your handler reads it behind a proxy.
::
::field{name="allowQuery" type="boolean | string[]"}
Allowlist of query parameter names that vary the cache key. **By default no query parameter varies the key or reaches the handler**, so `/page?a=1` and `/page?utm=x` share one entry and `url.searchParams` is empty inside the handler. :br :br
Set an array to opt specific names back in (case-sensitive, order-independent), or `true` to key on the full query string, which is appropriate for proxies and search endpoints where the parameters are not known ahead of time.
::
::field{name="allowCookies" type="string[]"}
Allowlist of cookie names that participate in caching. Listed cookies vary the cache key and are kept in the `cookie` header the handler sees. :br :br
**By default, no cookies are allowed**: the `cookie` request header is stripped before the handler runs, and any `set-cookie` header is dropped from the response before it is cached or returned, so a per-user cookie (such as a session id) can never leak to another user through the cache. Only allowlist cookies whose values are safe to share across every user hitting the same cache key (e.g. a `theme` or `locale` preference), never per-user secrets. :br :br
Setting this emits `Vary: Cookie`, which can collapse hit rates in CDNs and shared proxies. Consider moving the choice into the URL with `allowQuery` instead, or pairing it with `sendCacheControl: false`.
::
::field{name="allowAuthorization" type="boolean"}
Let `authorization` and `proxy-authorization` reach the handler and vary the cache key. Defaults to `false`, which strips both from cacheable requests. :br :br
Every client sending the same credential value shares one entry. When a response must never be shared, use `shouldBypassCache` instead.
::
::field{name="sendCacheControl" type="boolean"}
Whether to automatically set a `cache-control` response header. Defaults to `true`. :br
Set to `false` for server-only caching: responses are still stored and served from cache, but no `cache-control` header is advertised to clients and CDNs. This does not emit `no-store`, so downstream caches may still store the response under their own heuristics.
::
::field{name="cacheStatusHeader" type="boolean | string"}
Add a cache status response header (`X-Cache: HIT | STALE | REVALIDATED | MISS`). Defaults to `true`. Pass a string to use a custom header name, or `false` to disable.
::
::field{name="stream" type="boolean"}
Stream the response that fills a cache entry instead of buffering it first. Defaults to `false`. :br
Improves time to first byte for streaming renders, at the cost of a synthesized `etag` (the digest needs a finished body) and mid-body error recovery. Nothing partial is ever stored, and later requests are served the stored entry as usual.
::
::field{name="maxBodySize" type="number"}
Largest response body, in bytes, that may be buffered for storage. A larger response streams through uncached, exactly as a bypassed request does. :br
Defaults to the per-entry ceiling of the [cache storage](#cache-storage): derived from `cache.memory.maxBytes` for the `memory` driver (about 50 MB with the default 100 MB, since binary bodies are budgeted at twice their size). The `fs` and `kv` drivers declare no ceiling, so **there is no default limit** with them. Set this on any route that proxies upstream responses whose size you do not control.
::
::field{name="shouldCache()" type="(entry: ResponseCacheEntry) => boolean | Promise"}
Additional predicate deciding whether a response is cacheable, running on top of the [built-in checks](#what-gets-cached) (which always apply and can only be narrowed). Return `false` to skip caching a response; it is still returned to the caller, just not stored. :br
It runs on reads as well as writes, may be async, and fails closed: a throwing hook counts as "not cacheable" and reaches `onError`.
::
::
### Function-only options
These options are only available for `defineCachedFunction`:
::field-group
::field{name="transform()" type="(entry: CacheEntry, ...args) => any"}
Transform the cache entry before returning. The return value replaces the cached value. :br
The entry also carries a per-call `entry.status` (`"hit"`, `"stale"`, `"revalidated"` or `"miss"`) describing how the value was served, useful for metrics or conditional logic.
::
::field{name="serialize()" type="(entry: CacheEntry, ctx: { args }) => any"}
Prepare the resolved value for storage, right before the entry is persisted (the write-side counterpart of `transform`). Useful when the function returns something that cannot be stored as-is (e.g. a stream or a class instance): `serialize` converts it on write and `transform` restores it on read. :br
It runs once per resolution and is shared by deduplicated callers, so it may safely consume a one-use source such as a stream.
::
::field{name="validate()" type="(entry: CacheEntry, ctx: { args }) => boolean | Promise"}
Validate a cache entry. Return `false` (or a Promise resolving to `false`) to treat the entry as invalid and trigger re-resolution. The second argument carries the `args` of the current call, so the entry can be validated against it.
::
::
## SWR behavior
The `stale-while-revalidate` (SWR) pattern is **opt-in** via the `swr` option (disabled by default). Understanding how it interacts with other options:
| `swr` | `maxAge` | Behavior |
|-------|----------|----------|
| `false` (default) | `3600` | Cache for 1 hour, wait for the fresh value when expired |
| `true` | `3600` | Cache for 1 hour, serve stale while revalidating |
| `true` | `3600` with `staleMaxAge: 600` | Cache for 1 hour, serve stale for up to 10 more minutes while revalidating |
| `true` | `3600` with `staleMaxAge: 0` | Cache for 1 hour, never serve stale (same as `swr: false`) |
When `swr` is enabled and a cached value exists but has expired:
1. The stale cached value is returned immediately to the client.
2. The function/handler is called in the background to refresh the cache.
3. On edge workers, `event.waitUntil` is used to keep the background refresh alive.
When `swr` is disabled (default) and a cached value has expired:
1. The client waits while the function/handler resolves a fresh value.
2. The entry is replaced before the call returns (a `REVALIDATED` status).
::tip
Enable `swr` when slightly outdated data is acceptable (content pages, listings, mirrored third-party APIs) so response times stay flat even when the cache expires. Keep it disabled when callers must never receive stale data, such as access tokens or per-request authorization checks.
::
::warning
With `swr: true` and **no** `staleMaxAge`, entries carry no storage TTL: the last good value is served until the backend evicts it. That is exactly the ISR pattern, but it means cache growth is bounded by your backend's capacity, not by time. Set `staleMaxAge` for eventual cleanup.
::
## Cache keys
When using the `defineCachedFunction` or `defineCachedHandler` functions, the cache key is generated using the following pattern:
```ts
`${options.base}:${options.group}:${options.name}:${options.getKey(...args)}.json`
```
`group` and `name` are **escaped** before they enter the key: characters outside `[A-Za-z0-9_]` are removed and, when that changed the value, a hash of the raw value is appended. That is why Nitro's `nitro/functions` group appears as `nitrofunctions.` and cannot be confused with the `:` key structure. The `getKey` result is the terminal segment and is stored exactly as returned.
For example, the following function:
```ts
import { defineCachedFunction } from "nitro/cache";
const getAccessToken = defineCachedFunction(() => {
return String(Date.now())
}, {
maxAge: 10,
name: "getAccessToken",
getKey: () => "default"
});
```
Will be stored under a key like:
```ts
cache:nitrofunctions.3aFYDY_G88ZLdElv-hEB0r8snSViMYJ1NbRtlNP9o_0:getAccessToken:default.json
```
The default `base` is `/cache`, normalized to the `cache:` prefix (as with any other `/` in the key).
::note
For cached handlers, the generated key covers the request **origin** (scheme, host and port), the URL path, and the method, plus hashes of the values behind [`varies`](#handler-only-options), [`allowQuery`](#handler-only-options) and [`allowCookies`](#handler-only-options). `GET` and `HEAD` are stored separately.
::
::important
The `name` falls back to a hash of the **function source**, which cannot tell apart handlers or functions produced by a factory or a loop: they have identical source but different closed-over variables, so they share a key and overwrite each other's entries. Always pass an explicit `name` (or `getKey`) for those.
::
::note
Without `getKey`, the arguments are hashed. Opaque values (`Blob`, `ReadableStream`, `Promise`, `Request`, `WeakMap`) contribute only their type, so two different ones share an entry, and arguments nested deeper than 128 levels throw a `RangeError`. Pass a `getKey` reading only the fields the result depends on when either applies.
::
### What gets cached
Before a response is stored, the following checks apply. A response that fails any of them is still returned to the caller, it is just never stored or served from the cache:
| Check | Reason |
| ----- | ------ |
| Status is not `200`, `203`, `301` or `308` | Only these are complete, reusable representations. Errors, `204`/`304`, `206` ranges and temporary redirects are excluded. |
| `cache-control` opt-out from the handler | `no-store`, `private`, `no-cache`, or a zero shared lifetime (`s-maxage` when present, else `max-age`). |
| `Vary: *` | The strongest "do not share" signal short of `no-store`. |
| A `Vary` naming a header outside the cache key | Storing it would replay one variant to every client. Add the header to [`varies`](#handler-only-options) instead. |
| A missing body | Nothing to replay. An empty (zero-byte) `200` is fine and caches normally. |
| An `etag` or `last-modified` equal to the literal string `"undefined"` | The usual result of stringifying a missing value; it would break conditional requests. |
These checks always apply. Use [`shouldCache`](#handler-only-options) to narrow further. A response rejected by a built-in check also gets **no** synthesized `cache-control`, so a CDN in front cannot hold on to an error; a response rejected by your `shouldCache` still does.
::note
`cache-control: must-revalidate` is not an opt-out. The response is stored and served while fresh, but never while stale, so SWR is disabled for that response only.
::
::read-more{to="https://ocache.unjs.io/docs/cache-control"}
ocache: cache-control and eligibility in full.
::
## Cache invalidation
Cached entries can be invalidated programmatically at runtime (for example from a webhook when the underlying data changes) without waiting for `maxAge` to expire.
### `.invalidate()` and `.expire()` methods
Every function created with `defineCachedFunction` exposes on-demand revalidation methods:
- **`.invalidate(...args)`**: removes the cached entry entirely. The next call re-invokes the function and waits for the fresh value.
- **`.expire(...args)`**: marks the cached entry as stale without removing it. With `swr` enabled, the stale value is still served while the next call triggers a background refresh; without SWR, the next call re-resolves before returning.
- **`.resolveKeys(...args)`**: resolves the storage key(s) the entry is cached under (one per `base` prefix).
Arguments are passed through `getKey` to generate the cache key.
```ts
import { defineCachedFunction } from "nitro/cache";
const cachedGHStars = defineCachedFunction(async (repo: string) => {
const data = await fetch(`https://api.github.com/repos/${repo}`).then(res => res.json());
return data.stargazers_count;
}, {
maxAge: 60 * 60,
name: "ghStars",
getKey: (repo: string) => repo,
});
await cachedGHStars("unjs/nitro"); // populates the cache
await cachedGHStars.expire("unjs/nitro"); // marks the entry as stale
await cachedGHStars.invalidate("unjs/nitro"); // removes the entry
await cachedGHStars("unjs/nitro"); // re-invokes the function
```
If no cached entry matches the given arguments, `.invalidate()` and `.expire()` resolve without error and leave storage unchanged. Both also cancel a resolution already in flight for that key, so a pre-purge value cannot be written after the purge.
::note
Nitro's `defineCachedHandler` currently returns a plain event handler and does not expose these methods, so a cached route cannot be invalidated on demand. When you need that, move the expensive work into a `defineCachedFunction` called from a plain `defineHandler` and invalidate the function instead.
::
## Cache storage
Cache storage is configured with the [`cache`](/config/storage#cache) option. The `driver` option selects where entries are stored:
| Driver | Storage | Persisted |
| ------ | ------- | --------- |
| `memory` (default) | In-process LRU memory storage (ocache's `createMemoryStorage`). | No |
| `fs` | One file per entry under `cache.fs.dir` (defaults to `.data/cache`). Node.js compatible runtimes only. | Yes |
| `kv` | The [KV storage layer](/docs/storage) (`useKV()`), under the `cache:` prefix. Installs `unstorage` on demand. | Depends on the mount |
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
cache: {
driver: "fs",
fs: { dir: ".data/cache" },
},
});
```
The `memory` driver keeps entries in the server process, so they are **not persisted across restarts** or shared between workers. It evicts least recently used entries once it holds `cache.memory.maxSize` entries (default `10000`) or `cache.memory.maxBytes` bytes (default 100 MB):
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
cache: {
memory: { maxSize: 1000, maxBytes: 32 * 1024 * 1024 },
},
});
```
The `fs` driver never deletes expired files on its own (expired entries are not served and get overwritten on the next write). Responses varying by request values (`varies`, `allowQuery`) can create many files, so clean up the directory periodically.
With the `fs` driver, a relative `dir` resolves against `rootDir` in development and while prerendering, and against the working directory of the server process in production.
Use `$development` (and `$prerender`) to use a different driver during development:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
cache: { driver: "kv" },
$development: {
cache: { driver: "fs" },
},
});
```
### KV storage
The `kv` driver stores entries in the [KV storage layer](/docs/storage), which lets you use any [unstorage driver](https://unstorage.unjs.io/drivers) (Redis, Cloudflare KV, etc.) as cache backend. Mount it at the `cache` mount point using the `kv` option:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
kv: {
cache: {
driver: "redis",
/* redis connector options */
},
},
});
```
When a `cache` mount point (or one under the `cache:` / `cache/` prefix) is configured, `cache.driver` defaults to `kv`, so existing configurations keep storing cache entries in it. Set `cache.driver` explicitly to opt out. Mounts added at runtime (for example `useKV().mount("cache", ...)` in a plugin) cannot be detected at build time, so set `cache: { driver: "kv" }` explicitly in that case.
::important
If your cached functions, handlers or route rules use a custom [`base`](#shared-options) to store entries in another KV mount point (for example `base: "redis"`), set `cache: { driver: "kv" }` explicitly. Otherwise entries are stored with the default `memory` driver.
::
::note
Entries are serialized as JSON through unstorage, so the `kv` driver does not declare `binary`. A response body that decodes as UTF-8 (HTML, JSON, plain text) is stored as text either way; only a body that is **not** valid UTF-8 (images, fonts, PDFs) is base64-encoded, as are byte-valued cached functions. The `memory` and `fs` drivers declare `binary` and keep those bytes as bytes. :br :br
The `kv` driver also declares no per-entry size ceiling, which means cached handlers buffer any response body unless you set [`maxBodySize`](#handler-only-options).
::
### Global options
Options in `cache.defaults` are used as defaults for every cached function, cached handler and `cache` route rule. Options passed to `defineCachedFunction`, `defineCachedHandler` or a route rule take precedence.
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
cache: {
defaults: {
maxAge: 60,
swr: true,
staleMaxAge: 600,
cacheStatusHeader: false,
},
},
});
```
Shared defaults are `maxAge`, `swr`, `staleMaxAge`, `maxResolveTime` and `base`. Handler-only defaults (`varies`, `allowQuery`, `allowCookies`, `allowAuthorization`, `sendCacheControl`, `cacheStatusHeader` and `maxBodySize`) only apply to cached handlers and route rules.
::read-more{to="https://ocache.unjs.io/docs/storage"}
ocache: the storage interface, binary payloads and layered backends.
::
::read-more{to="/docs/storage"}
Read more about the Nitro storage layer and available drivers.
::
## Using route rules
Route rules let you enable caching for all routes matching a glob pattern, directly from your configuration. This is especially useful for a global cache strategy across a part of your application, without touching handler code.
Cache all the blog routes for 1 hour:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
"/blog/**": { cache: { maxAge: 60 * 60 } },
},
});
```
The `cache` rule accepts the same [handler options](#options) documented above (`swr`, `staleMaxAge`, `varies`, `allowQuery`, `allowCookies`, `allowAuthorization`, `sendCacheControl`, `cacheStatusHeader`, `stream`, `maxBodySize`, `headersOnly`, `base`, `group`, `name`, `integrity`, `maxResolveTime`). Hook options such as `shouldCache` are only available on `defineCachedHandler`.
If we want to use a custom [KV storage](#kv-storage) mount point, we can use the `base` option with the `kv` cache driver.
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
cache: { driver: "kv" },
kv: {
redis: {
driver: "redis",
url: "redis://localhost:6379",
},
},
routeRules: {
"/blog/**": { cache: { maxAge: 60 * 60, base: "redis" } },
},
});
```
### Route rules shortcuts
You can use the `swr` shortcut for enabling `stale-while-revalidate` caching on route rules. `swr: 3600` is shorthand for `cache: { swr: true, maxAge: 3600 }`. When set to `true`, SWR is enabled with the default `maxAge` of **1 second**, so prefer passing an explicit number.
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
"/blog/**": { swr: 3600 },
"/api/**": { swr: 60 },
},
});
```
To explicitly disable caching on a route, set `cache: false` (or `swr: false`) on a more specific pattern. This also removes a `cache` rule inherited from a broader pattern:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
"/api/**": { swr: 60 },
"/api/realtime/**": { cache: false },
},
});
```
::note
When using route rules, cached handlers use the group `'nitro/route-rules'` instead of the default `'nitro/handlers'`, and the generated `name` scopes the entry to the matched route handler, the HTTP method, the rule pattern and the matched route.
::
::warning
That generated scope is unique per process. With a **persistent** cache storage, a restart or a second worker therefore starts from fresh entries rather than reusing the ones already stored. Set an explicit `name` on the rule when route-rule cache entries need to be shared across processes.
::
::read-more{to="/docs/routing#route-rules"}
Route rules can do much more than caching: headers, redirects, proxying, and more.
::
---
# KV Storage
> Nitro provides a built-in storage layer that can abstract the filesystem, a database, or any other data source.
Nitro has built-in integration with [unstorage](https://unstorage.unjs.io) to provide a runtime-agnostic persistence layer: your code always calls the same key-value API, and the backing driver (memory, filesystem, Redis, ...) is swapped through configuration.
::note
`unstorage` is resolved from your project and installed automatically (without prompting): once you import `nitro/kv` (or `nitro/storage`), configure [`kv`](/config/storage#kv) mounts, or use the `kv` [cache driver](/docs/cache#cache-storage).
::
## Usage
To use the storage layer, you can use the `useKV()` utility to access the storage instance.
```ts
import { useKV } from "nitro/kv";
// Default storage (in-memory)
await useKV().setItem("test:foo", { hello: "world" });
const value = await useKV().getItem("test:foo");
// You can specify a base prefix with useKV(base)
const testStorage = useKV("test");
await testStorage.setItem("foo", { hello: "world" });
await testStorage.getItem("foo"); // { hello: "world" }
// You can use generics to type the return value
await useKV<{ hello: string }>("test").getItem("foo");
await useKV("test").getItem<{ hello: string }>("foo");
```
:read-more{to="https://unstorage.unjs.io"}
::note
`useKV` was previously named `useStorage` and exported from `nitro/storage`, and the `kv` config option was previously named `storage`. The old names still work as deprecated aliases. The deprecated `devStorage` option is replaced by `kv` inside `$development` (and `$prerender`).
::
### Available methods
The storage instance returned by `useKV()` provides the following methods:
| Method | Description |
|---|---|
| `getItem(key)` | Get the value of a key. Returns `null` if the key does not exist. |
| `getItems(items)` | Get multiple items at once. Accepts an array of keys or `{ key, options }` objects. |
| `getItemRaw(key)` | Get the raw value of a key without parsing. Useful for binary data. |
| `setItem(key, value)` | Set the value of a key. |
| `setItems(items)` | Set multiple items at once. Accepts an array of `{ key, value }` objects. |
| `setItemRaw(key, value)` | Set the raw value of a key without serialization. |
| `hasItem(key)` | Check if a key exists. Returns a boolean. |
| `removeItem(key)` | Remove a key from storage. |
| `getKeys(base?)` | Get all keys, optionally filtered by a base prefix. |
| `clear(base?)` | Clear all keys, optionally filtered by a base prefix. |
| `getMeta(key)` | Get metadata for a key (e.g., `mtime`, `atime`, `ttl`). |
| `setMeta(key, meta)` | Set metadata for a key. |
| `removeMeta(key)` | Remove metadata for a key. |
| `mount(base, driver)` | Dynamically mount a storage driver at a base path. |
| `unmount(base)` | Unmount a storage driver from a base path. |
| `watch(callback)` | Watch for changes. Callback receives `(event, key)` where event is `"update"` or `"remove"`. |
| `unwatch()` | Stop watching for changes. |
Shorthand aliases are also available: `get`, `set`, `has`, `del`, `remove`, `keys`.
```ts
import { useKV } from "nitro/kv";
// Get all keys under a prefix
const keys = await useKV("test").getKeys();
// Check if a key exists
const exists = await useKV().hasItem("test:foo");
// Remove a key
await useKV().removeItem("test:foo");
// Get raw binary data
const raw = await useKV().getItemRaw("assets:server:image.png");
// Get metadata (type, etag, mtime, etc.)
const meta = await useKV("assets:server").getMeta("file.txt");
```
## Configuration
You can mount one or multiple custom storage drivers using the `kv` option.
The key is the mount point name, and the value is the driver name and configuration.
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
kv: {
redis: {
driver: "redis",
/* redis connector options */
}
}
})
```
Then, you can use the redis storage using the `useKV("redis")` function.
::read-more{to="https://unstorage.unjs.io"}
You can find the driver list on [unstorage documentation](https://unstorage.unjs.io/) with their configuration.
::
::important
Mount options are serialized into the build output, so they must be JSON-serializable. Use [runtime configuration](#runtime-configuration) if a mount needs a non-serializable option.
::
### Driver dependencies
Some drivers rely on a third-party library (for example, `redis` requires [`ioredis`](https://www.npmjs.com/package/ioredis)).
Nitro detects the libraries required by the mounted drivers and prompts to install the missing ones (installed automatically in CI). Installed libraries are then explicitly passed to the driver via its `lib` option, so that bundlers can statically resolve them. `lib` options are therefore not configurable in `kv` mounts (set them to `null` to opt out of the injected import).
### Development storage
You can use the `$development` config key to override `kv` mounts during development.
This is useful when your production driver is not available in development (e.g., a managed Redis instance).
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
kv: {
db: {
driver: "redis",
host: "prod.example.com",
}
},
$development: {
kv: {
db: {
driver: "fs",
base: "./.data/db"
}
}
}
})
```
When running in development mode, `$development.kv` mounts are merged on top of `kv` mounts, allowing you to use a local filesystem driver or an in-memory driver while developing. A mount with a different `driver` replaces the whole mount (`host` is not passed to the `fs` driver above). A mount with the same `driver` has its options deep merged.
`$development` only applies to the development server. When [prerendering](/docs/prerendering) a production build, `$production` and then `$prerender` overrides apply instead. Use `$prerender` to override mounts during prerendering only:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
kv: {
db: { driver: "redis", host: "prod.example.com" }
},
$development: {
kv: { db: { driver: "fs", base: "./.data/db" } }
},
$prerender: {
kv: { db: { driver: "fs", base: "./.data/db" } }
}
})
```
::tip
A common setup is a hosted driver (e.g. `redis`) in `kv` for production and an `fs` driver in `$development.kv`, so development data is easy to inspect locally in `.data/` and does not require external services.
::
## Built-in mount points
Nitro automatically mounts the following storage paths:
### `/assets`
Server assets are mounted at the `/assets` base path (see [Server assets](#server-assets)). In production, this mount is read-only and only supports `getKeys()`, `hasItem()`, `getItem()`, `getItemRaw()`, and `getMeta()`.
```ts
import { useKV } from "nitro/kv";
// Access server assets via the /assets mount
const content = await useKV("assets:server").getItem("my-file.txt");
```
### Default (in-memory)
The root storage (without a base path) uses an in-memory driver by default. Data stored here is not persisted across restarts.
```ts
import { useKV } from "nitro/kv";
// In-memory by default, not persisted
await useKV().setItem("counter", 1);
```
To persist data, mount a driver with a persistent backend (e.g., `fs`, `redis`, etc.) using the `kv` configuration option.
::note
The [cache layer](/docs/cache) uses its own in-memory storage by default. Configuring a `cache` mount point (or setting [`cache.driver`](/config/storage#cache) to `kv`) stores cache entries in this storage layer instead.
::
## Server assets
Nitro allows you to bundle files from an `assets/` directory. These files are accessible at runtime via the `assets:server` storage mount.
The directory is resolved relative to [`serverDir`](/config/directories#serverdir) when it is set, and to the project root otherwise:
```
my-project/
server/
assets/
data.json
templates/
welcome.html
routes/
index.ts
```
```ts [server/routes/index.ts]
import { defineHandler } from "nitro";
import { useKV } from "nitro/kv";
export default defineHandler(async () => {
const serverAssets = useKV("assets:server");
const keys = await serverAssets.getKeys();
const data = await serverAssets.getItem("data.json");
const template = await serverAssets.getItem("templates/welcome.html");
return { keys, data, template };
});
```
### Custom asset directories
You can register additional asset directories using the `serverAssets` config option. Each `dir` is resolved relative to `rootDir` (not `serverDir`):
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
serverAssets: [
{
baseName: "templates",
dir: "./templates",
}
]
})
```
Custom asset directories are accessible under `assets:`:
```ts
import { useKV } from "nitro/kv";
const templates = useKV("assets:templates");
const keys = await templates.getKeys();
const html = await templates.getItem("email.html");
```
### Asset metadata
Server assets include metadata such as content type, ETag, and modification time:
```ts
import { useKV } from "nitro/kv";
const serverAssets = useKV("assets:server");
const meta = await serverAssets.getMeta("image.png");
// { type: "image/png", etag: "\"...\"", mtime: "2024-01-01T00:00:00.000Z" }
// Useful for setting response headers
const raw = await serverAssets.getItemRaw("image.png");
```
::note
In development (and while prerendering), server assets are read directly from the filesystem with the `fs` driver, so `getMeta()` returns filesystem stats (`mtime`, `size`, ...) instead of `type` and `etag`. In production, assets are bundled into the build output with their metadata pre-computed at build time.
::
::read-more{to="/docs/assets"}
Read more about public and server assets.
::
## Runtime configuration
In scenarios where the mount point configuration is not known until runtime, Nitro can dynamically add mount points during startup using [plugins](/docs/plugins).
```ts [plugins/storage.ts]
import { useKV } from "nitro/kv";
import { definePlugin } from "nitro";
import redisDriver from "unstorage/drivers/redis";
export default definePlugin(() => {
const storage = useKV()
// Dynamically pass in credentials from runtime configuration, or other sources
const driver = redisDriver({
base: "redis",
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
// Pass the driver library explicitly so that it is bundled
lib: () => import("ioredis"),
/* other redis connector options */
})
// Mount driver
storage.mount("redis", driver)
})
```
::important
Drivers mounted this way are outside of Nitro's [driver dependency detection](#driver-dependencies): install the third-party library yourself (`ioredis` in the example above) and pass it via the driver's `lib` option. Without it, the driver falls back to a dynamic `import("ioredis")` that bundlers cannot statically resolve, and the mount fails at runtime with a `Cannot import ioredis` error.
::
---
# Assets
> Serve static files to clients and bundle files into your server for runtime access.
Nitro supports three types of assets: **public assets** served directly to clients, **imported files** inlined into the server bundle, and **server assets** bundled into the server for programmatic access.
## Public Assets
Nitro handles assets via the `public/` directory.
All assets in `public/` directory will be automatically served. This means that you can access them directly from the browser without any special configuration.
```md
public/
image.png <-- /image.png
video.mp4 <-- /video.mp4
robots.txt <-- /robots.txt
```
### Caching and Headers
Public assets are served with automatic `ETag` and `Last-Modified` headers for conditional requests. When the client sends `If-None-Match` or `If-Modified-Since` headers, Nitro returns a `304 Not Modified` response.
For assets served from a non-root `baseURL` (such as `/build/`), Nitro prevents fallthrough to application handlers. If a request matches a public asset base but the file is not found, a `404` with `Cache-Control: no-store` is returned immediately. This keeps the `maxAge` cache header of the directory off missing files.
### Production Public Assets
When building your Nitro app, the `public/` directory will be copied to `.output/public/` and a manifest with metadata will be created and embedded in the server bundle.
```json
{
"/image.png": {
"type": "image/png",
"etag": "\"4a0c-6utWq0Kbk5OqDmksYCa9XV8irnM\"",
"mtime": "2023-03-04T21:39:45.086Z",
"size": 18956
},
"/robots.txt": {
"type": "text/plain; charset=utf-8",
"etag": "\"8-hMqyDrA8fJ0R904zgEPs3L55Jls\"",
"mtime": "2023-03-04T21:39:45.086Z",
"size": 8
},
"/video.mp4": {
"type": "video/mp4",
"etag": "\"9b943-4UwfQXKUjPCesGPr6J5j7GzNYGU\"",
"mtime": "2023-03-04T21:39:45.085Z",
"size": 637251
}
}
```
This allows Nitro to know the public assets without scanning the directory, giving high performance with caching headers.
### Custom Public Asset Directories
You can configure additional public asset directories using the `publicAssets` config option. Each entry supports the following properties:
- `dir` -- Path to the directory (resolved relative to `rootDir`).
- `baseURL` -- URL prefix for serving assets (default: `"/"`).
- `maxAge` -- Cache `max-age` in seconds. When set, a `Cache-Control: public, max-age=, immutable` header is applied via route rules. Only takes effect when `fallthrough` is `false` (the default for non-root directories).
- `fallthrough` -- Whether requests should fall through to application handlers when the asset is not found. Top-level (`baseURL: "/"`) directories default to `true`; non-root directories default to `false`.
- `ignore` -- Pass `false` to disable ignore patterns, or an array of glob patterns to override the global `ignore` option.
```js [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
publicAssets: [
{
baseURL: "build",
dir: "public/build",
maxAge: 3600,
},
],
});
```
In this example, files in `public/build/` are served under `/build/` with a one-hour cache and no fallthrough to application handlers.
### Compressed Public Assets
Nitro can generate pre-compressed versions of your public assets during the build. When a client sends an `Accept-Encoding` header, the server will serve the compressed version if available. Supported encodings are gzip (`.gz`), brotli (`.br`), and zstd (`.zst`).
Set `compressPublicAssets: true` to enable all encodings:
```js [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
compressPublicAssets: true,
});
```
Or pick specific encodings:
```js [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
compressPublicAssets: {
gzip: true,
brotli: true,
zstd: false,
},
});
```
::note
Only compressible MIME types (text, JavaScript, JSON, XML, WASM, fonts, SVG, etc.) with a file size of at least 1 KB are compressed. Source map files (`.map`) are excluded. zstd compression requires Node.js 22+ (`zlib.zstdCompress`) at build time and is silently skipped on older versions.
::
Assets written to the public directory by `close` hooks or other late build steps are also compressed at the end of the build.
## Importing Files
Any file can be imported with a `type` import attribute. Its contents are inlined into the server bundle as a `Uint8Array` with [`type: "bytes"`](https://github.com/tc39/proposal-import-bytes) or as a UTF-8 `string` with [`type: "text"`](https://github.com/tc39/proposal-import-text):
```ts [routes/logo.ts]
import { defineHandler } from "nitro";
import logo from "./logo.png" with { type: "bytes" }; // Uint8Array
import readme from "./README.md" with { type: "text" }; // string
export default defineHandler(() => {
return new Response(logo, { headers: { "content-type": "image/png" } });
});
```
The file type is ignored: any file can be imported as either bytes or text.
Dynamic imports are supported as well:
```ts
const { default: logo } = await import("./logo.png", { with: { type: "bytes" } });
```
Alternatively, the `raw:` prefix imports a file as a `string` (for text files, based on the file type) or a `Uint8Array` (for binary files):
```ts
import readme from "raw:./README.md"; // string
import logo from "raw:./logo.png"; // Uint8Array
```
::note
Imported files are inlined into the server bundle (base64 encoded for binary files). Prefer [server assets](#server-assets) for larger files.
::
::note
TypeScript does not support the `bytes` and `text` attributes yet. Until then, imports of unknown file types need a `// @ts-ignore` comment.
::
## Files Referenced by URL
Files referenced relative to a module with `new URL("./file", import.meta.url)` are copied to the server output (`.output/server/assets/`), and the URL is rewritten to point to the copy. They are not inlined, so this also works for large files, such as the `.wasm` and `.data` files of packages built with emscripten or wasm-bindgen (for example [`@electric-sql/pglite`](https://pglite.dev)).
```ts [routes/model.ts]
import { readFile } from "node:fs/promises";
import { defineHandler } from "nitro";
export default defineHandler(async () => {
const model = await readFile(new URL("./model.bin", import.meta.url));
return { size: model.byteLength };
});
```
::note
This is supported for presets with filesystem access (Node.js, Bun, Deno and the Node.js functions of Vercel and Netlify). The URL must be a string literal and `import.meta.url` the second argument. JavaScript and TypeScript files, URLs with a query or hash, and files that don't exist at build time are left as they are.
With Vite, this applies to the Nitro environment, including the dependencies of service environments (such as `ssr`), which Nitro bundles. Files referenced by the source of a service environment itself are not copied. In development, modules are loaded from their original location, so URLs are not rewritten.
::
## Server Assets
All assets in the `assets/` directory (relative to [`serverDir`](/config/directories#serverdir) when it is set, otherwise the project root) will be added to the server bundle. After building your application, you can find them in the `.output/server/chunks/raw/` directory. Be careful with the size of your assets, as they will be bundled with the server bundle.
::tip
If your code never uses `useKV()`, server assets won't be included in the server bundle.
::
They can be addressed by the `assets:server` mount point using the [storage layer](/docs/storage).
For example, you could store a json file in `assets/data.json` and retrieve it in your handler:
```js
import { defineHandler } from "nitro";
import { useKV } from "nitro/kv";
export default defineHandler(async () => {
const data = await useKV("assets:server").get("data.json");
return data;
});
```
### Custom Server Assets
In order to add assets from a custom directory, you will need to define a path in your nitro config. This allows you to add assets from a directory outside of the `assets/` directory.
Each entry in `serverAssets` supports the following properties:
- `baseName` -- Name used as the storage mount point (accessed via `assets:`).
- `dir` -- Path to the directory (resolved relative to `rootDir`).
- `pattern` -- Glob pattern for file inclusion (default: `"**/*"`).
- `ignore` -- Array of glob patterns to exclude files.
```js [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
serverAssets: [
{
baseName: "templates",
dir: "./templates",
},
],
});
```
Then you can use the `assets:templates` base to retrieve your assets.
```ts [handlers/success.ts]
import { defineHandler } from "nitro";
import { useKV } from "nitro/kv";
export default defineHandler(async (event) => {
const html = await useKV("assets:templates").get("success.html");
return html;
});
```
::note
During development, server assets are read directly from the filesystem using the `fs` unstorage driver. In production, they are bundled into the server as lazy imports with pre-computed metadata (MIME type, ETag, modification time).
::
::read-more{to="/docs/storage"}
Server assets are accessed through the storage layer. See the storage docs for the full list of available methods.
::
---
# Configuration
> Customize and extend Nitro defaults.
::read-more{to="/config"}
See [config reference](/config) for available options.
::
## Config file
You can customize Nitro with a configuration file. Depending on your setup, define options in `nitro.config.ts`, or under the `nitro` key in `vite.config.ts` when using the Vite plugin.
::code-group
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
// Nitro options
})
```
```ts [vite.config.ts]
import { defineConfig } from 'vite'
import { nitro } from 'nitro/vite'
export default defineConfig({
plugins: [
nitro()
],
nitro: {
// Nitro options
}
})
```
::
### Supported configuration files
Nitro loads its configuration using [c12](https://github.com/unjs/c12) from the project root directory (the directory passed to the `nitro` CLI, defaulting to the current working directory). The following locations are checked in order and the first matching file is used:
- `nitro.config.{js,ts,mjs,cjs,mts,cts,json,jsonc,json5,yaml,yml,toml}`
- `.config/nitro.{js,ts,mjs,cjs,mts,cts,json,jsonc,json5,yaml,yml,toml}`
- `.config/nitro.config.{js,ts,mjs,cjs,mts,cts,json,jsonc,json5,yaml,yml,toml}`
Using `nitro.config.ts` is the recommended convention.
An extensionless `.nitrorc` file in the same directory is also loaded (using `key=value` syntax) and merged with lower priority than the main configuration file.
When using the Vite plugin, options can also be passed inline with the `nitro` key in `vite.config.ts` (as shown above).
### Environment-specific config
Using [c12](https://github.com/unjs/c12) conventions, you can provide environment-specific overrides using `$development` and `$production` keys:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
logLevel: 3,
$development: {
// Options applied only in development mode
debug: true,
},
$production: {
// Options applied only in production builds
minify: true,
},
})
```
The environment name is `"development"` during `nitro dev` and `"production"` during `nitro build`. Use the `--envName` flag to set a custom name, and the `$env` key for its overrides:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
$env: {
staging: {
// Options applied only with `--envName staging`
runtimeConfig: { apiBase: "https://staging.example.com" },
},
},
})
```
```sh
npx nitro build --envName staging
```
Routes prerendered during that build apply the same overrides. Tools that use the [`nitro/builder`](/docs/utils#nitrobuilder) API can pass the name with `createNitro(config, { c12: { envName: "staging" } })`.
The resolved name is available as `nitro.options.envName`, and as `import.meta.envName` in server code (replaced statically at build time):
```ts [routes/env.ts]
import { defineHandler } from "nitro";
export default defineHandler(() => ({ env: import.meta.envName }));
```
### Extending configs
You can extend from other configs or presets using the `extends` key:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
extends: "./base.config",
})
```
Resolved layers are available to [modules](/docs/modules) as `nitro.options.layers`, ordered from highest to lowest priority. The first layer is always the project root. Each layer has a `cwd` (layer directory), an optional `configFile`, its own `config` (before merging), and an optional `meta`. Presets are not included.
```ts [modules/layers.ts]
import type { NitroModule } from "nitro/types";
export default {
setup(nitro) {
for (const layer of nitro.options.layers) {
nitro.logger.info(`Layer: ${layer.meta?.name || layer.cwd}`);
}
},
} satisfies NitroModule;
```
## Directory options
Nitro provides several options for controlling directory structure:
| Option | Default | Description |
| --- | --- | --- |
| `rootDir` | `.` (current directory) | The root directory of the project. |
| `serverDir` | `false` | Server source directory (set to `"server"` or `"./"` to enable). |
| `buildDir` | `node_modules/.nitro` | Directory for build artifacts. |
| `output.dir` | `.output` | Production output directory. |
| `output.serverDir` | `.output/server` | Server output directory. |
| `output.publicDir` | `.output/public` | Public assets output directory. |
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
serverDir: "server",
buildDir: "node_modules/.nitro",
output: {
dir: ".output",
},
})
```
::note
The `srcDir` option is deprecated. Use `serverDir` instead.
::
:read-more{to="/config/directories"}
## Environment variables
Certain Nitro behaviors can be configured using environment variables. These variables configure Nitro itself at build and startup time. To expose your own values to the application, use [runtime configuration](#runtime-configuration) instead.
| Variable | Description |
| --- | --- |
| `NITRO_PRESET` | Override the deployment preset. |
| `NITRO_COMPATIBILITY_DATE` | Set the compatibility date. |
| `NITRO_APP_BASE_URL` | Override the base URL (default: `/`). |
| `NITRO_BUILDER` | Select the bundler to use for building (`rollup`, `rolldown`, or `vite`). |
| `NITRO_DEV_RELOAD_TIMEOUT` | How long (in milliseconds) `vite dev` requests wait for the server entry to finish loading or reloading before they fall back to the previous entry, or return a 503 if none has loaded yet (default: `120000`). |
| `NITRO_ENV_PREFIX` | Set a custom secondary prefix for runtime config environment overrides (default: `_`). |
| `NITRO_ENV_EXPANSION` | Enable environment variable expansion in runtime config values. |
## Runtime configuration
Runtime config lets you expose configuration values to your application and override them at runtime using environment variables. This is useful for values that differ between environments (development, staging, production), such as API endpoints or feature flags.
First, define the runtime config in your configuration file.
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
runtimeConfig: {
apiToken: "dev_token", // `dev_token` is the default value
}
});
```
You can now access the runtime config using `useRuntimeConfig()`.
```ts [api/example.get.ts]
import { defineHandler } from "nitro";
import { useRuntimeConfig } from "nitro/runtime-config";
export default defineHandler((event) => {
return useRuntimeConfig().apiToken; // Returns `dev_token`
});
```
### Nested objects
Runtime config supports nested objects. Keys at any depth are mapped to environment variables using the `NITRO_` prefix and `UPPER_SNAKE_CASE` conversion:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
runtimeConfig: {
database: {
host: "localhost",
port: 5432,
},
},
});
```
```bash [.env]
NITRO_DATABASE_HOST="db.example.com"
NITRO_DATABASE_PORT="5433"
```
::note
Only keys defined in `runtimeConfig` in your config file will be considered. You cannot introduce new keys using environment variables alone.
::
### Serialization
Runtime config values must be serializable (strings, numbers, booleans, plain objects, and arrays). Non-serializable values (class instances, functions, etc.) will trigger a warning at build time.
Values that are `undefined` or `null` in the config are replaced with empty strings (`""`) as a fallback.
### Local development
You can update the runtime config using environment variables. You can use a `.env` or `.env.local` file locally and use platform variables in production (see below).
Create an `.env` file in your project root:
```bash [.env]
NITRO_API_TOKEN="123"
```
Restart the development server, fetch the `/api/example` endpoint, and you should see `123` as the response instead of `dev_token`.
::note
The `.env` and `.env.local` files are loaded when Nitro resolves your config, in both `nitro dev` and `nitro build`, so `nitro.config.ts` can read them via `process.env`. Variables already present in the environment take precedence over `.env` values. They are **not** loaded by the built server at runtime: in production, use your platform's native environment variable mechanism.
::
You can still access environment variables directly using `import.meta.env` or `process.env`, but avoid reading them in ambient global contexts (at the top level of modules) to prevent unexpected behavior.
### Production
You can define variables in your production environment to update the runtime config.
::warning
All variables must be prefixed with `NITRO_` to be applied to the runtime config. They will override the runtime config variables defined within your `nitro.config.ts` file.
::
```bash
NITRO_API_TOKEN="123"
```
Keys are camelCase in runtime config and UPPER_SNAKE_CASE in environment variables.
```ts
{
helloWorld: "foo"
}
```
```bash
NITRO_HELLO_WORLD="foo"
```
### Custom env prefix
You can configure a secondary environment variable prefix using the `nitro.envPrefix` runtime config key. This prefix is checked in addition to the default `NITRO_` prefix:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
runtimeConfig: {
nitro: {
envPrefix: "APP_",
},
apiToken: "",
},
});
```
With this configuration, both `NITRO_API_TOKEN` and `APP_API_TOKEN` will be checked as overrides.
::note
When no custom prefix is configured, the default secondary prefix is `_`. For example, `_API_TOKEN` also overrides `apiToken`.
::
### Env expansion
When enabled, environment variable references using `{{VAR_NAME}}` syntax in runtime config string values are expanded at runtime:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
experimental: {
envExpansion: true,
},
runtimeConfig: {
url: "https://{{APP_DOMAIN}}/api",
},
});
```
```bash
APP_DOMAIN="example.com"
```
At runtime, `useRuntimeConfig().url` will resolve to `"https://example.com/api"`.
---
# Database
> Nitro provides a built-in and lightweight SQL database layer.
> Nitro provides a built-in and lightweight SQL database layer.
The default database connection is **preconfigured** with [SQLite](https://db0.unjs.io/connectors/sqlite) and works out of the box for development mode and any Node.js compatible production deployments. By default, data will be stored in `.data/db.sqlite`.
:read-more{to="https://db0.unjs.io" title="DB0 Documentation"}
::important
Database support is currently experimental.
Refer to the [db0 issues](https://github.com/unjs/db0/issues) for status and bug report.
::
The database layer is opt-in. Enable the experimental feature flag:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
experimental: {
database: true
}
})
```
`db0` is resolved from your project and installed automatically (without prompting) once `experimental.database` is enabled or you import `nitro/database`.
## Usage
```ts [server.ts]
import { defineHandler } from "nitro";
import { useDatabase } from "nitro/database";
export default defineHandler(async () => {
const db = useDatabase();
// Create users table
await db.sql`DROP TABLE IF EXISTS users`;
await db.sql`CREATE TABLE IF NOT EXISTS users ("id" TEXT PRIMARY KEY, "firstName" TEXT, "lastName" TEXT, "email" TEXT)`;
// Add a new user
const userId = String(Math.round(Math.random() * 10_000));
await db.sql`INSERT INTO users VALUES (${userId}, 'John', 'Doe', '')`;
// Query for users
const { rows } = await db.sql`SELECT * FROM users WHERE id = ${userId}`;
return {
rows,
};
});
```
### `useDatabase`
Use `useDatabase` to get a database instance. It accepts an optional connection name (defaults to `"default"`).
```ts
import { useDatabase } from "nitro/database";
// Use the default connection
const db = useDatabase();
// Use a named connection
const usersDb = useDatabase("users");
```
Database instances are created lazily on first use and cached for subsequent calls with the same connection name. If a connection name is not configured, an error will be thrown.
### `db.sql`
Execute SQL queries using tagged template literals with automatic parameter binding:
```ts
const db = useDatabase();
// Insert with parameterized values (safe from SQL injection)
const id = "1001";
await db.sql`INSERT INTO users VALUES (${id}, 'John', 'Doe', 'john@example.com')`;
// Query with parameters
const { rows } = await db.sql`SELECT * FROM users WHERE id = ${id}`;
// The result includes rows, changes count, and last insert ID
const result = await db.sql`INSERT INTO posts (title) VALUES (${"Hello"})`;
// result.rows, result.changes, result.lastInsertRowid
```
### `db.exec`
Execute a raw SQL string directly:
```ts
const db = useDatabase();
await db.exec("CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT)");
```
### `db.prepare`
Prepare an SQL statement for repeated execution:
```ts
const db = useDatabase();
const stmt = db.prepare("SELECT * FROM users WHERE id = ?");
const result = await stmt.bind("1001").all();
```
When the experimental [`tracingChannel`](/config/advanced#tracingchannel) option is enabled, queries made with `exec()`, `sql` and prepared statements on `useDatabase()` instances are published on the `db0.query` tracing channel. Queries made through `getInstance()`, or by an ORM using the underlying client, are not traced.
::note
Nitro's built-in tracing sinks (the [console logger](/config/features#tracinglogger), Vercel and Cloudflare) record the query method and SQL dialect, not the query text. The channel message itself includes the full query text, so any other subscriber, such as an APM agent, receives it. Failed queries are recorded with their error message, which some databases build from parts of the query.
::
## Configuration
You can change the default connection or define additional named connections to any of the [supported databases](https://db0.unjs.io/connectors/sqlite) using the `database` config, and integrate the database instance with any of the [supported ORMs](https://db0.unjs.io/integrations).
Each connection is a `DatabaseConnectionConfig` with a `connector` name and an optional `options` object. Connector-specific settings (such as `url`, `host`, or `name`) belong under `options`, not at the top level of the connection config.
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
database: {
default: {
connector: "sqlite",
options: { name: "db" },
},
users: {
connector: "postgresql",
options: {
url: "postgresql://username:password@hostname:port/database_name",
},
},
analytics: {
connector: "mysql2",
options: {
host: "localhost",
port: 3306,
user: "root",
password: "password",
database: "analytics",
},
},
},
});
```
See the [db0 connector docs](https://db0.unjs.io/connectors) for the `options` each connector accepts.
### Development Database
Use the `devDatabase` config to override the database configuration **only for development mode**. This is useful for using a local SQLite database during development while targeting a different database in production.
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
database: {
default: {
connector: "postgresql",
options: {
url: "postgresql://username:password@hostname:port/database_name"
}
}
},
devDatabase: {
default: {
connector: "sqlite",
options: { name: "dev-db" }
}
}
});
```
::note
In development, a provided `devDatabase` **replaces** the `database` configuration entirely; connections are not merged per-name. Include every connection you need during development in `devDatabase`.
::
::tip
When `experimental.database` is enabled and no `database` or `devDatabase` config is provided, Nitro automatically configures a default SQLite connection. In development mode, data is stored relative to the project root directory. In Node.js production, it uses the default SQLite path.
::
## Connectors
Nitro supports all [db0 connectors](https://db0.unjs.io/connectors). The `connector` field in the database config accepts any of the following values:
| Connector | Description |
|---|---|
| `sqlite` | Node.js built-in SQLite (alias for `node-sqlite`) |
| `node-sqlite` | Node.js built-in SQLite |
| `better-sqlite3` | [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) |
| `sqlite3` | [sqlite3](https://github.com/TryGhost/node-sqlite3) |
| `bun` / `bun-sqlite` | Bun built-in SQLite |
| `libsql` / `libsql-node` | [libSQL](https://github.com/tursodatabase/libsql) (Node.js) |
| `libsql-http` | libSQL over HTTP |
| `libsql-web` | libSQL for web environments |
| `libsql-core` | libSQL with a user provided client |
| `postgresql` | [PostgreSQL](https://github.com/brianc/node-postgres) |
| `mysql2` | [MySQL](https://github.com/sidorares/node-mysql2) |
| `pglite` | [PGlite](https://github.com/electric-sql/pglite) (embedded PostgreSQL) |
| `planetscale` | [PlanetScale](https://github.com/planetscale/database-js) serverless |
| `neon` | [Neon](https://github.com/neondatabase/serverless) serverless |
| `cloudflare-d1` | [Cloudflare D1](https://developers.cloudflare.com/d1/) |
| `cloudflare-hyperdrive-mysql` | Cloudflare Hyperdrive with MySQL |
| `cloudflare-hyperdrive-postgresql` | Cloudflare Hyperdrive with PostgreSQL |
### Connector dependencies
Some connectors rely on a third-party library (for example, `postgresql` requires [`pg`](https://www.npmjs.com/package/pg)).
Nitro detects the libraries required by the configured connections and prompts to install the missing ones (installed automatically in CI). Installed libraries are then explicitly passed to the connector via its `lib` option, so that bundlers can statically resolve them.
---
# Lifecycle
> Understand how Nitro runs and serves incoming requests to your application.
## Server startup
When the server starts, before it accepts any request, Nitro:
1. Evaluates the top-level code of the [server entry](/docs/server-entry#startup-logic).
2. Creates the Nitro app and runs [runtime plugins](/docs/plugins) in order. Requests that arrive while [async plugins](/docs/plugins#async-plugins) are still running are held until they finish.
On serverless and edge presets, this happens on every cold start.
## Request lifecycle
A request can be intercepted and terminated (with or without a response) from any of these layers, in this order:
::steps
### `request` hook
The `request` hook is the first code that runs for every incoming request. It is registered via a [runtime plugin](/docs/plugins):
```ts [plugins/request-hook.ts]
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("request", (event) => {
console.log(`Incoming request on ${event.req.url}`);
});
});
```
::note
Errors thrown inside the `request` hook are captured by the [`error` hook](#error-handling) and do not terminate the request pipeline.
::
### Route rules
Matching [route rules](/docs/routing#route-rules) from the Nitro config execute next. Route rules run as middleware, before any global middleware, and most of them alter the response without terminating it (for instance, adding a header or setting a cache policy). The matched rules are resolved before any middleware runs and are available as `event.context.routeRules` in every middleware.
The `cache` (and `swr`) rule is the exception: it caches the route handler, so it runs after global and routed middleware, right before the matched route. Middleware runs on every request to a cached route, whether the response comes from the cache or not.
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
'/**': { headers: { 'x-nitro': 'first' } }
}
})
```
:read-more{to="/docs/routing#route-rules" title="Routing > Route rules"}
### Static assets
When static asset serving is enabled (the default for most presets), Nitro registers a static assets handler as the **first global middleware**. It checks if the request matches a file in the `public/` directory before other global middleware and route handlers run.
If a match is found, the static file is served immediately with appropriate `Content-Type`, `ETag`, `Last-Modified`, and `Cache-Control` headers. The request is terminated and no further middleware or routes are executed.
Static assets also support content negotiation for pre-compressed files (gzip, brotli, zstd) via the `Accept-Encoding` header.
### Global middleware
Any global middleware defined in the `middleware/` directory runs next:
```ts [middleware/info.ts]
import { defineHandler } from "nitro";
export default defineHandler((event) => {
event.context.info = { name: "Nitro" };
});
```
::warning
Returning from a middleware will close the request and should be avoided when possible.
::
::read-more{to="/docs/routing#middleware"}
Learn more about Nitro middleware.
::
### Routed middleware
Middleware registered for a specific route pattern (via the [`handlers` config](/docs/routing#route-scoped-middleware)) runs after global middleware. Any middleware attached to the matched route itself runs last, right before the route handler.
### Routes
Nitro matches the incoming request against the routes defined in the `routes/` folder.
```ts [routes/api/hello.ts]
export default (event) => ({ world: true })
```
::read-more{to="/docs/routing#filesystem-routing"}
Learn more about Nitro filesystem routing.
::
### Server entry
If a server entry is defined, it catches all requests not matched by any route, acting as a `/**` route handler.
```ts [server.ts]
import { defineHandler } from "nitro";
export default defineHandler((event) => {
if (event.url.pathname === "/") {
return "Home page";
}
});
```
::read-more{to="/docs/server-entry"}
Learn more about the Nitro server entry.
::
### Renderer
If no route is matched, Nitro will look for a renderer handler (defined or auto-detected) to handle the request.
::read-more{to="/docs/renderer"}
Learn more about Nitro renderer.
::
### `response` hook
After the response is created (from any of the layers above), the `response` hook runs. This hook receives the final `Response` object and the event, and can be used to inspect or modify response headers:
```ts [plugins/response-hook.ts]
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("response", (res, event) => {
console.log(`Response ${res.status} for ${event.req.url}`);
});
});
```
::note
The `response` hook runs for every response, including static assets, middleware-terminated requests, and error responses.
::
::
## Error handling
When an error occurs at any point in the request lifecycle, Nitro:
1. Calls the `error` hook with the error and context (including the event and source tags).
2. Passes the error to the **error handler** which converts it into an HTTP response.
### Throwing errors
Throw an `HTTPError` from any handler or middleware to end the request with a specific status code, message, and optional data:
```ts [routes/signup.ts]
import { defineHandler, HTTPError } from "nitro";
export default defineHandler((event) => {
// Message and details
throw new HTTPError("Invalid user input", { status: 400 });
// Status code shortcut
throw HTTPError.status(400, "Bad Request");
// Full object form
throw new HTTPError({
status: 400,
message: "Invalid user input",
data: { field: "email" },
});
});
```
::note
Any other thrown value (such as a plain `new Error()`) is treated as an **unhandled** error: the response is always `500` and the message, data, and stack are hidden from the client for security.
::
### Default error responses
In production, the default error handler always responds with JSON:
```json
{
"error": true,
"status": 400,
"message": "Invalid user input",
"data": { "field": "email" }
}
```
In development, programmatic requests receive the same JSON format, but when the request's `Accept` header includes `text/html` (browsers), Nitro renders an HTML error page with a source-mapped stack trace and highlighted code frames.
### Custom error handlers
To control error response formatting, set `errorHandler` in the Nitro config to a file that exports a handler with `defineErrorHandler`:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
errorHandler: "~/error",
});
```
```ts [error.ts]
import { defineErrorHandler } from "nitro";
export default defineErrorHandler((error, event) => {
return new Response(`[custom error] ${error.message}`, {
status: error.status || 500,
headers: { "Content-Type": "text/plain" },
});
});
```
Return a `Response` to send it to the client, or return nothing to fall through to the next handler. `errorHandler` also accepts an array of handlers that run in order: the first one that returns a response wins, and the built-in default handler is always appended as the last entry, so there is always a fallback response:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
errorHandler: ["~/error/known-errors", "~/error/fallback"],
});
```
If a handler itself throws, the failure is logged and the chain continues with the next handler. Each handler also receives the built-in `defaultHandler` as `{ defaultHandler }` in its third argument, so you can reuse the default rendering within your own logic.
::read-more{to="/examples/custom-error-handler"}
See a full custom error handler example.
::
To customize error rendering for the development server only (without affecting the production bundle), pass a handler function as `devErrorHandler`:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
import errorHandler from "./error.ts";
export default defineConfig({
devErrorHandler: errorHandler,
});
```
### Observing errors
To observe errors without changing the response, use the `error` runtime hook from a [plugin](/docs/plugins):
```ts [plugins/errors.ts]
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("error", (error, context) => {
console.error("Captured error:", error);
// context.event - the H3 event (if available)
// context.tags - error source tags like "request", "response", "plugin"
});
});
```
You can also feed errors into the same pipeline programmatically with [`nitroApp.captureError`](/docs/plugins#programmatic-error-capture). Errors are also tracked per-request in `event.req.context.nitro.errors` for inspection in later hooks.
Additionally, unhandled promise rejections and uncaught exceptions at the process level are automatically captured into the `error` hook with the tags `"unhandledRejection"` and `"uncaughtException"`.
## Server shutdown
When the Nitro server is shutting down, the `close` hook is called. Use this to clean up resources such as database connections, timers, or external service handles:
```ts [plugins/cleanup.ts]
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("close", async () => {
// Clean up resources
});
});
```
::note
The hook is awaited after the HTTP server stops accepting connections. Long-running server
presets (Node.js, Bun and Deno) call it on `SIGINT` and `SIGTERM`. Serverless and edge presets
have no shutdown signal, so the hook only runs there if the platform closes the runtime.
::
## Hooks reference
All runtime hooks are registered through [runtime plugins](/docs/plugins) using `nitroApp.hooks.hook()`.
| Hook | Signature | When it runs |
| --- | --- | --- |
| `request` | `(event: HTTPEvent) => void \| Promise` | Start of each request, before routing. |
| `response` | `(res: Response, event: HTTPEvent) => void \| Promise` | After the response is created, before it is sent. |
| `error` | `(error: Error, context: { event?, tags? }) => void` | When any error is captured during the lifecycle. |
| `close` | `() => void` | When the Nitro server is shutting down. |
::note
The `NitroRuntimeHooks` interface is augmentable. Deployment presets (such as Cloudflare) can extend it with platform-specific hooks.
::
::read-more{to="/docs/plugins"}
Learn more about Nitro plugins and hook usage examples.
::
---
# OpenAPI
> Nitro can automatically generate an [OpenAPI](https://www.openapis.org/) specification from your route handlers and serve interactive API documentation.
Nitro scans all route handlers, extracts metadata defined with `defineRouteMeta`, and generates an [OpenAPI 3.1.0](https://spec.openapis.org/oas/v3.1.0) specification. Built-in UIs powered by [Scalar](https://scalar.com/) and [Swagger UI](https://swagger.io/tools/swagger-ui/) let you explore and test your API directly in the browser.
::important
OpenAPI support is currently experimental.
::
## Enable OpenAPI
Enable OpenAPI in your Nitro configuration:
::code-group
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
experimental: {
openAPI: true,
},
});
```
::
Once enabled, the following endpoints become available during development:
| Endpoint | Description |
|---|---|
| `/_openapi.json` | OpenAPI 3.1.0 JSON specification |
| `/_scalar` | Scalar API reference UI |
| `/_swagger` | Swagger UI |
## Route Metadata
Use the `defineRouteMeta` macro in route handler files to provide OpenAPI metadata for each route. The `openAPI` property accepts a standard OpenAPI [Operation Object](https://spec.openapis.org/oas/v3.1.0#operation-object).
```ts [routes/api/hello.ts]
import { defineRouteMeta, defineHandler } from "nitro";
defineRouteMeta({
openAPI: {
tags: ["greeting"],
description: "Returns a greeting message",
responses: {
200: { description: "Successful greeting" },
},
},
});
export default defineHandler(() => {
return { message: "Hello, world!" };
});
```
::note
`defineRouteMeta` is a build-time macro. The metadata is statically extracted during the build and does not add any runtime overhead to your handlers.
::
### Parameters
Route parameters (`:id`, `[id]`) are automatically converted to OpenAPI path parameters. You can define additional query or header parameters in the `parameters` array:
```ts [routes/api/users/[id].get.ts]
import { defineRouteMeta, defineHandler } from "nitro";
defineRouteMeta({
openAPI: {
tags: ["users"],
description: "Get a user by their ID",
parameters: [
{
in: "query",
name: "include",
description: "Comma-separated list of related resources to include",
schema: { type: "string" },
},
],
responses: {
200: { description: "User found" },
404: { description: "User not found" },
},
},
});
export default defineHandler((event) => {
const { id } = event.context.params;
return { id, name: "Alice" };
});
```
In this example, the `id` path parameter is automatically inferred from the route pattern. Only the additional `include` query parameter needs to be declared.
### Response Schemas
Define response content types and schemas using the standard OpenAPI `responses` object:
```ts [routes/api/status.ts]
import { defineRouteMeta, defineHandler } from "nitro";
defineRouteMeta({
openAPI: {
description: "Returns the current server status",
responses: {
200: {
description: "Server status",
content: {
"application/json": {
schema: {
type: "object",
properties: {
status: { type: "string", enum: ["healthy", "degraded"] },
uptime: { type: "number" },
},
},
},
},
},
},
},
});
export default defineHandler(() => {
return { status: "healthy", uptime: process.uptime() };
});
```
### Global Components
Use the `$global` property to define reusable schemas that are hoisted to the top-level `components` section of the OpenAPI specification. This lets you reference shared schemas with `$ref` across multiple routes.
```ts [routes/api/users.get.ts]
import { defineRouteMeta, defineHandler } from "nitro";
defineRouteMeta({
openAPI: {
tags: ["users"],
description: "List all users",
responses: {
200: {
description: "List of users",
content: {
"application/json": {
schema: {
type: "array",
items: { $ref: "#/components/schemas/User" },
},
},
},
},
},
$global: {
components: {
schemas: {
User: {
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
email: { type: "string", format: "email" },
},
},
},
},
},
},
});
export default defineHandler(() => {
return [{ id: "1", name: "Alice", email: "alice@example.com" }];
});
```
Once defined, the `User` schema can be referenced from any other route with `{ $ref: "#/components/schemas/User" }` without re-declaring it.
### Automatic Tagging
Routes are automatically tagged based on their path prefix:
| Route prefix | Tag |
|---|---|
| `/api/` | API Routes |
| `/_` | Internal |
| Other | App Routes |
You can override this by specifying `tags` in the `openAPI` metadata.
## Configuration
Configure OpenAPI behavior with the top-level `openAPI` option:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
experimental: {
openAPI: true,
},
openAPI: {
meta: {
title: "My API",
description: "My awesome API",
version: "2.0.0",
},
},
});
```
### `meta`
Set the API metadata that appears in the specification's `info` object:
| Property | Default | Description |
|---|---|---|
| `title` | `"Nitro Server Routes"` | API title |
| `description` | β | API description |
| `version` | `"1.0.0"` | API version |
### `route`
- Default: `"/_openapi.json"`
Override the path where the OpenAPI JSON specification is served:
```ts [nitro.config.ts]
export default defineConfig({
openAPI: {
route: "/_docs/openapi.json",
},
});
```
### `ui`
Configure or disable the built-in API documentation UIs:
```ts [nitro.config.ts]
export default defineConfig({
openAPI: {
ui: {
scalar: {
route: "/_docs/scalar",
theme: "purple",
},
swagger: {
route: "/_docs/swagger",
},
},
},
});
```
Set either UI to `false`{lang=ts} to disable it:
```ts [nitro.config.ts]
export default defineConfig({
openAPI: {
ui: {
swagger: false,
},
},
});
```
:read-more{to="https://github.com/scalar/scalar" title="Scalar Configuration"}
## Production
By default, OpenAPI endpoints are only available during development. To enable them in production, set the `production` option:
```ts [nitro.config.ts]
export default defineConfig({
openAPI: {
production: "runtime",
},
});
```
| Value | Behavior |
|---|---|
| `false`{lang=ts} | Disabled in production (default) |
| `"runtime"`{lang=ts} | Specification is generated at runtime on each request |
| `"prerender"`{lang=ts} | Specification is generated at build time and served as a static file |
Use `"prerender"`{lang=ts} when the specification does not change between deployments for the best performance. Use `"runtime"`{lang=ts} if you need dynamic server information or middleware access.
::warning
If you enable OpenAPI in production, make sure to protect the endpoints with appropriate authentication or access control.
::
---
# Plugins
> Use runtime plugins to extend Nitro's runtime behavior.
Runtime plugins are **executed once** during server startup, making them the right place for one-time initialization and for registering [lifecycle hooks](/docs/lifecycle). Each plugin receives the `nitroApp` context.
Plugins are auto-registered from the `plugins/` directory and run in order. A plugin can also be [async](#async-plugins).
```ts [plugins/test.ts]
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
console.log('Nitro plugin', nitroApp)
})
```
If you have plugins in another directory, you can use the `plugins` option:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
plugins: ['my-plugins/hello.ts']
})
```
Plugins run in this order:
1. Plugins listed in the `plugins` option, in the order they are listed.
2. Plugins auto-registered from `plugins/` directories. Directories are processed in [`scanDirs`](/config/directories#scandirs) order, and files within each directory are sorted by path.
## Async plugins
A plugin can return a promise, for example to connect to a database before the server handles traffic:
```ts [plugins/db.ts]
import { definePlugin } from "nitro";
export default definePlugin(async (nitroApp) => {
const db = await connectToDatabase();
nitroApp.hooks.hook("close", () => db.close());
});
```
The plugins after an async plugin run once it resolves, so they can rely on what it set up. Until all plugins have finished:
- Incoming requests are held and handled once initialization completes.
- Platform events dispatched as hooks (such as `cloudflare:scheduled` or `cloudflare:queue`) and scheduled tasks are deferred as well.
- `nitroApp.ready` is a promise that resolves when initialization completes. It is `undefined` once the app is ready.
This does not rely on top-level `await`, so it works on every deployment target. Once all plugins have resolved, requests are handled without any extra overhead.
If a plugin rejects, the plugins after it do not run. The error is passed to the `error` hook and reported as an unhandled rejection (like an error thrown by a sync plugin), and requests fail with a `500` response from then on.
::warning
Do not `await` a request to the app itself (such as `fetch("/api/...")`) inside an async plugin: requests are held until the plugin resolves, so it would never complete.
::
::note
Platforms such as Cloudflare Workers do not allow I/O (like `fetch()` or opening sockets) outside of a request. Plugins run at startup, so on these platforms set up connections lazily (on first use) instead.
::
## The `nitroApp` context
The plugin function receives a `nitroApp` object with the following properties:
| Property | Type | Description |
| --- | --- | --- |
| `hooks` | [`HookableCore`](https://github.com/unjs/hookable) | Hook system for registering lifecycle callbacks. |
| `h3` | `H3Core` | The underlying [H3](https://github.com/h3js/h3) application instance. |
| `fetch` | `(req: Request) => Response \| Promise` | The app's internal fetch handler. |
| `captureError` | `(error: Error, context) => void` | Programmatically capture errors into the error hook pipeline. |
| `ready` | `Promise \| undefined` | Set while [async plugins](#async-plugins) are initializing. |
::note
H3 composes the middleware chain once, on the first request. Middleware a plugin adds to `nitroApp.h3["~middleware"]` during startup is included, but it runs before Nitro's route rules when placed at the front of the array. If a plugin changes the array after the first request was handled, it must also reset `nitroApp.h3["~dispatch"]` and `nitroApp.h3["~composed"]` to `undefined` so the chain is recomposed.
::
## Nitro runtime hooks
Use Nitro [hooks](https://github.com/unjs/hookable) to run custom functions at specific points in the [request lifecycle](/docs/lifecycle). Register them inside plugins with `nitroApp.hooks.hook()`:
```ts [plugins/close.ts]
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("close", async () => {
// Will run when nitro is being closed
});
})
```
### Available hooks
| Hook | Signature | Description |
| --- | --- | --- |
| `request` | `(event: HTTPEvent) => void \| Promise` | Called at the start of each request. |
| `response` | `(res: Response, event: HTTPEvent) => void \| Promise` | Called after the response is created. |
| `error` | `(error: Error, context: { event?: HTTPEvent, tags?: string[] }) => void` | Called when an error is captured. |
| `close` | `() => void` | Called when the Nitro server is shutting down. |
> [!NOTE]
> The `NitroRuntimeHooks` interface is augmentable. Deployment presets (such as Cloudflare) can extend it with platform-specific hooks like `cloudflare:scheduled` and `cloudflare:email`.
### Unregistering hooks
The `hook()` method returns an unregister function that can be called to remove the hook:
```ts [plugins/example.ts]
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
const unregister = nitroApp.hooks.hook("request", (event) => {
// ...
});
// Later, remove the hook
unregister();
});
```
## Examples
### Capturing errors
You can use plugins to capture all application errors.
```ts [plugins/errors.ts]
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("error", async (error, { event }) => {
console.error(`${event?.req.url} Application error:`, error)
});
})
```
The `context` object includes an optional `tags` array that identifies the error source (e.g., `"request"`, `"response"`, `"cache"`, `"plugin"`, `"unhandledRejection"`, `"uncaughtException"`).
### Programmatic error capture
You can use `captureError` to manually feed errors into the error hook pipeline:
```ts [plugins/capture.ts]
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.captureError(new Error("something went wrong"), {
tags: ["startup"],
});
});
```
### Graceful shutdown
The server shuts down gracefully, waiting for any pending background tasks started with `event.waitUntil`. Use the `close` hook to clean up your own resources:
```ts [plugins/shutdown.ts]
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("close", async () => {
// Clean up resources, close connections, etc.
});
});
```
### Request and response lifecycle
You can use plugins to register hooks that run on the [request lifecycle](/docs/lifecycle):
```ts [plugins/lifecycle.ts]
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("request", (event) => {
console.log("on request", event.req.url);
});
nitroApp.hooks.hook("response", (res, event) => {
// Modify or inspect the response
console.log("on response", res.status);
});
});
```
### Modifying response headers
```ts [plugins/headers.ts]
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("response", (res, event) => {
const { pathname } = new URL(event.req.url);
if (pathname.endsWith(".css") || pathname.endsWith(".js")) {
res.headers.append("Vary", "Origin");
}
});
});
```
---
# Prerendering
> Render routes at build time and serve them as static assets.
When prerendering is enabled, Nitro fetches the specified routes during the build and writes the responses to the `.output/public` directory. Prerendered routes are then served as static files. No server rendering happens at request time, which makes them fast, cacheable, and deployable to any static hosting or CDN.
Prerendering is a good fit for routes whose content does not change per request: landing pages, documentation, blog posts, or JSON endpoints with constant output. Dynamic routes can keep being served by the server, and you can freely mix prerendered and server-rendered routes in the same app.
## Prerendering routes
### `prerender.routes` config
List the routes to prerender using the `prerender.routes` option:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
prerender: {
routes: ["/", "/about", "/api/content.json"],
},
});
```
HTML responses are written as `index.html` files within subfolders (`/about` produces `about/index.html`), while other responses keep their path (`/api/content.json` produces `api/content.json`). See [`autoSubfolderIndex`](#options) to change this behavior.
### `prerender` route rule
Alternatively, mark routes for prerendering with the `prerender` [route rule](/docs/routing#route-rules):
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
"/about": { prerender: true },
},
});
```
::warning
Only **exact paths** are added to the prerender list from route rules. Wildcard patterns like `"/blog/**": { prerender: true }` are **not** auto-expanded, since Nitro cannot know which routes match the pattern. To prerender a dynamic set of routes, use [link crawling](#crawling-links) or the [`prerender:routes` hook](#hooks).
::
## Crawling links
Instead of (or in addition to) listing every route, enable `crawlLinks` to let Nitro discover routes by following links in prerendered HTML pages:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
prerender: {
crawlLinks: true,
routes: ["/"],
},
});
```
The crawler extracts `href` attributes from each prerendered HTML page and queues the discovered routes for prerendering. External links, hash links, and links to files with extensions (except `.json`) are skipped. Relative links are resolved against the page they were found on.
If `crawlLinks` is enabled and no routes are specified, crawling starts from `/`.
::tip
Handlers can queue additional routes for prerendering by setting the `x-nitro-prerender` response header to a comma-separated list of paths. This works even when `crawlLinks` is disabled and also applies to non-HTML responses, such as JSON.
::
## Ignoring routes
Use `prerender.ignore` to skip routes. Each entry can be a string prefix, a route pattern, a regular expression, or a function receiving the path:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
prerender: {
crawlLinks: true,
ignore: [
"/admin", // Ignore routes starting with /admin
"/blog/**", // Ignore /blog and everything under it
"/users/:id", // Ignore /users/1, but not /users/1/posts
/\/preview$/, // Ignore routes ending with /preview
(path) => path.includes("draft"), // Ignore routes containing "draft"
],
},
});
```
A string containing `*` or `:` is matched as a [rou3](https://github.com/h3js/rou3) route pattern against the path (without its query string), the same syntax as [route rules](/docs/routing#route-rules). Any other string is matched as a prefix, so `"/admin"` also ignores `/admin-panel`, while `"/admin/**"` does not.
::note
`*` and `**` both match across segments: `"/blog/*"` ignores `/blog/a/b` too. Both also match the base path itself (`/blog`). `:name` matches exactly one segment.
::
You can also exclude routes with the `prerender: false` route rule. Unlike enabling prerendering, wildcard patterns **do** work for exclusion since they are matched against each candidate route:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
"/dynamic/**": { prerender: false },
},
});
```
### Public assets
Routes served by [public assets](/docs/assets) with a base URL prefix are always skipped. Additionally, you can set `prerender.ignoreUnprefixedPublicAssets: true` to skip prerendering routes that are already served by unprefixed public assets, avoiding duplicate files in the output:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
prerender: {
crawlLinks: true,
ignoreUnprefixedPublicAssets: true,
},
});
```
## Options
| Option | Default | Description |
|--------|---------|-------------|
| `failOnError` | `false` | Fail the build when a route cannot be prerendered. Useful in CI. Independently of this option, the build always fails if every prerendered route failed and nothing was written to the public directory. |
| `ignoreStatusCodes` | `[]` | Status codes that do not fail the build when `failOnError` is enabled, for example `[404]` to allow broken links. Failed routes with those status codes are still reported and not written. |
| `concurrency` | `1` | Maximum number of routes prerendered in parallel. |
| `interval` | `0` | Delay in milliseconds between prerender requests. |
| `autoSubfolderIndex` | `true` | Write HTML routes as subfolder indexes. |
The `concurrency` and `interval` options control prerendering speed. Increase `concurrency` to speed up large sites, or add an `interval` to avoid hitting rate limits when routes call external APIs.
`autoSubfolderIndex` controls how HTML files are written to `.output/public`:
```bash
# autoSubfolderIndex: true (default)
/about -> .output/public/about/index.html
# autoSubfolderIndex: false
/about -> .output/public/about.html
```
Set it to `false` when your hosting provider does not let you configure trailing slash behavior.
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
prerender: {
crawlLinks: true,
failOnError: true,
ignoreStatusCodes: [404],
concurrency: 8,
autoSubfolderIndex: false,
},
});
```
## Hooks
For advanced use cases, you can hook into the prerendering lifecycle at build time. These hooks are available to [modules](/docs/modules) and the `hooks` config option:
| Hook | Description |
|------|-------------|
| `prerender:routes` | Called with the initial `Set` of routes, to add or remove entries before prerendering starts. |
| `prerender:config` | Called with the config of the internal Nitro instance used for prerendering. |
| `prerender:generate` | Called for each route before its file is written, to inspect or modify the route object (`route.contents`, `route.fileName`, ...) or set `route.skip = true`. |
| `prerender:route` | Called after each route is generated. |
| `prerender:done` | Called once prerendering is finished, with the lists of prerendered and failed routes. |
A common use case is registering routes dynamically, for example from a CMS or database:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
modules: [
(nitro) => {
nitro.hooks.hook("prerender:routes", async (routes) => {
const posts = await fetch("https://api.example.com/posts").then((res) => res.json());
for (const post of posts) {
routes.add(`/blog/${post.slug}`);
}
});
},
],
});
```
## Static hosting
To ship a fully prerendered site without a server bundle, use a static preset such as `static`, `github_pages`, or `gitlab_pages`. These presets skip the server build, enable `crawlLinks`, and only output the `public` directory, ready to upload to any static hosting. See the [deploy documentation](/deploy) and providers like [GitHub Pages](/deploy/providers/github-pages) for details.
::note
The `static: true` config option only disables the server build; it does not enable prerendering by itself. When using it directly instead of a static preset, configure `prerender` options (typically `crawlLinks: true`) to generate the pages.
::
---
# Tasks
> Nitro tasks allow one-off operations at runtime: named server-side actions such as database migrations, cache cleanup, or content syncing that you can run on demand, on a schedule, or from the CLI.
## Opt-in to the experimental feature
::important
Tasks support is currently experimental.
See [nitrojs/nitro#1974](https://github.com/nitrojs/nitro/issues/1974) for the relevant discussion.
::
In order to use the tasks API you need to enable the experimental feature flag. Tasks are scanned from your [`serverDir`](/docs/configuration), which has to be set as well:
```ts [nitro.config.ts]
import { defineConfig } from "nitro";
export default defineConfig({
serverDir: "./server",
experimental: {
tasks: true
}
})
```
All examples on this page assume this config.
## Define tasks
Tasks can be defined in `tasks/[name].ts` files inside your [`serverDir`](/docs/configuration).
::note
Like routes and plugins, tasks are only scanned when [`serverDir`](/docs/configuration) (or `scanDirs`) is set; no directories are scanned by default.
::
Nested directories are supported. The task name will be joined with `:`.
```txt
server/
tasks/
cleanup.ts <-- cleanup
db/
migrate.ts <-- db:migrate
nitro.config.ts
```
**Example:**
```ts [server/tasks/db/migrate.ts]
import { defineTask } from "nitro/task";
export default defineTask({
meta: {
name: "db:migrate",
description: "Run database migrations",
},
run({ payload, context }) {
console.log("Running DB migration task...");
return { result: "Success" };
},
});
```
### Task interface
The `defineTask` helper accepts an object with the following properties:
- **`meta`** (optional): An object with optional `name` and `description` string fields used for display in the dev server and CLI.
- **`run`** (required): A function that receives a [`TaskEvent`](#taskevent) and returns (or resolves to) an object with an optional `result` property.
```ts
interface Task