nitro
Nitro is a server toolkit that turns a directory of route files into a deployable server for whatever platform you are targeting. You write handlers in routes/ and middleware in middleware/, and Nitro compiles them into an output bundle shaped for the preset you picked: a plain Node server, a Cloudflare Worker module, a Vercel or Netlify function, Bun, Deno, and about a dozen others. On top of routing it gives you a KV layer (useStorage, backed by unstorage), response and function caching with stale-while-revalidate, per-route rules for caching, redirects, proxying, CORS and basic auth declared as config, typed runtime config fed from environment variables, websockets, and a database helper. It is the server half of Nuxt, extracted so any project can use it. Important context for the version you install: the nitro package on npm currently publishes the v3 line under date-stamped beta versions, while stable Nitro v2 still ships under the old name, nitropack.
Genuinely good at the thing it claims: one server codebase, many deploy targets, with caching and storage already solved. Install nitropack if you need stable today, and treat the nitro package as the beta it is labelled until v3 drops the tag.
Use it if
- You need one server codebase that deploys to Node, Bun, Deno, Cloudflare Workers, Vercel and Netlify without rewriting the handlers for each target
- You are on Nuxt already: this is the server that Nuxt runs on, so understanding Nitro is understanding your own production runtime
- You have a Vite app and want API routes next to it rather than a second repository and a second deployment
- You want caching, KV storage, redirects, proxying and prerendering as declarative route rules instead of five middleware packages you wire together yourself
- You want file-based routing with method suffixes and dynamic segments, and typed runtime config that can be overridden by environment variables without a rebuild
- You want a stable release: the latest tag on the nitro package is a dated v3 beta, the migration guide describes itself as a living document, and stable Nitro is still the nitropack package (2.13.4, published April 2026) which is what Nuxt 4.5 depends on
- You have a v2 app and no appetite for a rewrite: v3 moves to h3 v2, so event.node is Node-only, readBody and the send helpers are gone in favour of event.req.json() and returning values, createError becomes HTTPError, every runtime import moves to a nitro/* subpath, several presets were renamed or removed, and app.config support was dropped
- You are shipping a plain HTTP API on one known host: Fastify or Hono is a dependency and a listen call, while Nitro adds a build step, a preset, a bundler and a compiled output directory
- You want a small blast radius when something breaks: a Nitro app pulls in h3, srvx, unstorage, ocache, unenv, crossws, db0 and rolldown, and a production bug can live in any of them rather than in Nitro itself
- You believe deploy-anywhere means write-once: Cloudflare bindings come off event.req.runtime.cloudflare, ISR only exists on Vercel, and each preset has its own runtime limits you still have to code around
- You are on Node 18 or older, or you depend on features still behind experimental flags such as tasks and defineRouteMeta
Setup reality
Two decisions before you type anything. First, which package: npm install nitro gets you the v3 beta, npm install nitropack gets you stable v2, and the two have different import paths and a migration guide between them. Second, which entry point: v3 is Vite-first, so the documented path is adding the nitro() plugin to vite.config.ts and pointing serverDir at your server folder, with an optional nitro.config.ts on the side for preset and route rules. Node 20.19 or 22.12 and newer is enforced by engines. Most of the peer dependencies are optional (vite, rollup, jiti, dotenv, xml2js, giget), so your package manager may warn loudly about peers you will never install. Configuration is loaded by c12, which means a .nitrorc in your project or home directory can also change behaviour, and $development and $production blocks override the base config depending on whether you ran dev or build. The part that costs the most time is the deployment preset: it changes the output shape, the available runtime APIs and the meaning of some route rules, so test against the real target early rather than assuming the Node dev server told you the truth.
Patterns
Attach Nitro to an existing Vite appadd-to-vite-project
// vite.config.ts
import { defineConfig } from "vite";
import { nitro } from "nitro/vite";
export default defineConfig({
plugins: [nitro()],
});
// nitro.config.ts
import { defineConfig } from "nitro";
export default defineConfig({
serverDir: "./server",
});serverDir is where Nitro scans for routes/, middleware/ and plugins/. Without it, your handlers are never found and every request falls through to the Vite app.
Define API routes by filenamefile-based-route
// server/routes/api/health.ts -> GET|* /api/health
import { defineHandler } from "nitro";
export default defineHandler(() => ({ ok: true }));
// server/routes/users.post.ts -> POST /users
export default defineHandler(async (event) => {
const body = await event.req.json();
return { created: body.name };
});The method suffix goes before the extension and only accepts real HTTP methods. In v3 the body comes from the native Request on event.req, so readBody from Nitro 2 no longer exists.
Read path parametersdynamic-params
// server/routes/api/[org]/[repo]/issues.ts -> /api/:org/:repo/issues
import { defineHandler } from "nitro";
export default defineHandler((event) => {
const { org, repo } = event.context.params;
return { org, repo };
});
// server/routes/files/[...path].ts catches /files/a/b/c
One parameter per path segment: [a]-[b].ts in a single filename is not supported, each param needs its own folder. A catch-all keeps the slashes in the captured value.
Run code before every handlermiddleware
// server/middleware/auth.ts
import { defineHandler, HTTPError } from "nitro";
export default defineHandler((event) => {
const token = event.req.headers.get("authorization");
if (!token) throw new HTTPError({ status: 401, message: "No token" });
event.context.user = decode(token);
});Everything in middleware/ runs for every route. Returning a value from middleware ends the request and sends that value as the response, which is almost never what you meant; throw an HTTPError instead.
Cache a response with stale-while-revalidatecached-handler
import { defineCachedHandler } from "nitro/cache";
export default defineCachedHandler(
async () => expensiveQuery(),
{ maxAge: 60 * 60, swr: true, varies: ["accept-language"] }
);Request headers are dropped when a cached response is served, so anything that varies by header must be listed in varies or you will serve one user's response to another. Only GET and HEAD are cached; other methods bypass it.
Cache one expensive call inside a handlercached-function
import { defineHandler } from "nitro";
import { defineCachedFunction } from "nitro/cache";
const cachedStars = defineCachedFunction(
async (repo: string) => {
const data = await fetch(`https://api.github.com/repos/${repo}`).then(r => r.json());
return data.stargazers_count;
},
{ name: "ghStars", maxAge: 60 * 60, getKey: (repo: string) => repo }
);
export default defineHandler(async (event) => {
const { repo } = event.context.params;
return { repo, stars: await cachedStars(repo).catch(() => 0) };
});getKey has to include every argument that changes the result, or two different calls share one cache entry. Concurrent calls for the same key are deduplicated to a single invocation.
Declare caching, redirects and proxying as configroute-rules
// nitro.config.ts
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
"/blog/**": { swr: 600 },
"/api/realtime/**": { cache: false },
"/old-docs/**": { redirect: { to: "/docs", status: 308 } },
"/upstream/**": { proxy: "https://api.internal.example.com/**" },
"/admin/**": { basicAuth: { username: "ops", password: process.env.OPS_PW } },
"/about": { prerender: true },
},
});swr: 600 is shorthand for cache: { swr: true, maxAge: 600 }. isr only does anything on the Vercel preset, so a rule that works in staging can be a silent no-op on another target.
Read config that environment variables can overrideruntime-config
// nitro.config.ts
export default defineConfig({
runtimeConfig: {
apiToken: "dev_token",
database: { host: "localhost", port: 5432 },
},
});
// server/routes/api/example.get.ts
import { defineHandler } from "nitro";
import { useRuntimeConfig } from "nitro/runtime-config";
export default defineHandler(() => useRuntimeConfig().apiToken);
// .env
// NITRO_API_TOKEN=prod_token
// NITRO_DATABASE_HOST=db.example.comOnly keys declared in runtimeConfig can be overridden; an environment variable with no matching key is ignored entirely. Values must be serializable, and undefined or null defaults become empty strings.
Read and write key-value datakv-storage
import { useStorage } from "nitro/storage";
await useStorage("cache").setItem("report:2026-08", { rows: 120 });
const report = await useStorage<{ rows: number }>("cache").getItem("report:2026-08");
const keys = await useStorage("cache").getKeys();The default driver is in-memory, which means it is empty after every cold start and not shared between serverless instances. Mount a real driver (Redis, filesystem, Cloudflare KV) in config before you rely on anything persisting.
Run code once when the server bootsstartup-plugin
// server/plugins/metrics.ts
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("error", async (error, { event }) => {
reportToSentry(error, { url: event?.url });
});
});Plugins run in filename order and the plugin function itself must be synchronous, though the hooks it registers can be async. There is no per-request plugin: this happens once per server instance, and on serverless that means once per cold start.
Serve websocketswebsocket-handler
// nitro.config.ts
export default defineConfig({ features: { websocket: true } });
// server/routes/_ws.ts
import { defineWebSocketHandler } from "nitro";
export default defineWebSocketHandler({
open(peer) { peer.send(`welcome ${peer.id}`); },
message(peer, message) { peer.send(message.text()); },
close(peer) { console.log("closed", peer.id); },
});The feature flag is required; without it the route exists but never upgrades. Cross-platform support comes from crossws, so behaviour on Cloudflare Durable Objects is not identical to a long-lived Node process.
Build for a specific deployment targetbuild-for-target
# preset from config
export default defineConfig({ preset: "node" });
# or from the environment, useful in CI
NITRO_PRESET=cloudflare_module npx nitro build
NITRO_PRESET=vercel npx nitro buildPreset names changed in v3: cloudflare and cloudflare_worker collapsed into cloudflare_module, vercel-edge is gone in favour of vercel with fluid compute, and the old node preset is now node_middleware. Stale preset names in a CI script fail the build rather than falling back.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| nitropack | npm | You want the same toolkit on its stable v2 line, which is what Nuxt currently ships |
| hono | npm | You want multi-runtime portability from a small router with no build step and no preset system |
| fastify | npm | You are deploying to Node servers you control and want a mature plugin ecosystem and schema-based validation |
| h3 | npm | You like Nitro's handler model but want just the HTTP layer without the builder, presets and caching |