Lifecycle
Request lifecycle
A request can be intercepted and terminated (with or without a response) from any of these layers, in this order:
request hook
The request hook is the first code that runs for every incoming request. It is registered via a runtime plugin:
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("request", (event) => {
console.log(`Incoming request on ${event.req.url}`);
});
});
request hook are captured by the error hook and do not terminate the request pipeline.Route rules
Matching 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).
import { defineConfig } from "nitro";
export default defineConfig({
routeRules: {
'/**': { headers: { 'x-nitro': 'first' } }
}
})
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:
import { defineHandler } from "nitro";
export default defineHandler((event) => {
event.context.info = { name: "Nitro" };
});
Routed middleware
Middleware registered for a specific route pattern (via the handlers config) 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.
export default (event) => ({ world: true })
Server entry
If a server entry is defined, it catches all requests not matched by any route, acting as a /** route handler.
import { defineHandler } from "nitro";
export default defineHandler((event) => {
if (event.url.pathname === "/") {
return "Home page";
}
});
Renderer
If no route is matched, Nitro will look for a renderer handler (defined or auto-detected) to handle the request.
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:
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("response", (res, event) => {
console.log(`Response ${res.status} for ${event.req.url}`);
});
});
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:
Calls the error hook with the error and context (including the event and source tags).
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:
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" },
});
});
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:
{
"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 interactive HTML error page with a source-mapped stack trace.
Custom error handlers
To control error response formatting, set errorHandler in the Nitro config to a file that exports a handler with defineErrorHandler:
import { defineConfig } from "nitro";
export default defineConfig({
errorHandler: "~/error",
});
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:
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.
To customize error rendering for the development server only (without affecting the production bundle), pass a handler function as devErrorHandler:
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:
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. 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:
import { definePlugin } from "nitro";
export default definePlugin((nitroApp) => {
nitroApp.hooks.hook("close", async () => {
// Clean up resources
});
});
Hooks reference
All runtime hooks are registered through runtime plugins using nitroApp.hooks.hook().
| Hook | Signature | When it runs |
|---|---|---|
request | (event: HTTPEvent) => void | Promise<void> | Start of each request, before routing. |
response | (res: Response, event: HTTPEvent) => void | Promise<void> | 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. |
NitroRuntimeHooks interface is augmentable. Deployment presets (such as Cloudflare) can extend it with platform-specific hooks.