mrkeyoor.com_
Thu 06 Aug 10:54 UTC
npmWeb Backendupdated 06 Aug 2026

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.

Verdict

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.

API stability2/5The version you get from the latest tag is a dated beta of a major rewrite. The migration guide is explicitly a living document, h3 v2 changed request and response handling wholesale, runtime imports moved to new subpaths, presets were renamed or deleted, and tasks and route meta are still behind experimental labels.
Docs4/5nitro.build has task-shaped guides for routing, caching, storage, database, plugins, tasks and websockets, a full route-rules table, and a detailed v2 to v3 migration guide that is shipped inside the package itself. It is still catching up with the beta in places, and some pages assume you already know the unjs ecosystem.
Maintenance4/5Pushed 4 August 2026 with beta builds cut roughly every few weeks, developed inside the unjs organisation with paid contributors rather than one volunteer. The counterweight is 496 open issues (608 including PRs) and a v3 line that has been in alpha or beta since late 2025.
Ecosystem5/5Around 15M installs a week across the nitro and nitropack names, 11k stars, and presets covering AWS Lambda, Amplify, Azure, Bun, Cloudflare, Deno, Netlify, Vercel, Zeabur and more. Being the server layer under Nuxt means a very large body of real deployments and community answers.

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
Skip it if

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

Only 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 build

Preset 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

PackageRegistryPick it when
nitropacknpmYou want the same toolkit on its stable v2 line, which is what Nuxt currently ships
hononpmYou want multi-runtime portability from a small router with no build step and no preset system
fastifynpmYou are deploying to Node servers you control and want a mature plugin ecosystem and schema-based validation
h3npmYou like Nitro's handler model but want just the HTTP layer without the builder, presets and caching