File: README.md
H3
H3 (pronounced as /eɪtʃθriː/, like h-3) is a minimal h(ttp) framework built for high performance and portability.
[!NOTE]
You are on the v2 active branch. Check out tree/v1 and v1.h3.dev for legacy v1.
Contribution
Local development
- Clone this repository
- Install the latest LTS version of Node.js
- Enable Corepack using
corepack enable - Install dependencies using
pnpm install - Run tests using
pnpm devorpnpm test
License
Published under the MIT license.
Made by @pi0 and community 💛
🤖 auto updated with automd
File: docs/1.guide/1.basics/1.lifecycle.md
icon: icon-park-outline:handle-round
Request Lifecycle
H3 dispatches incoming web requests to final web responses.
Below is an overview of what happens in a H3 server from when an HTTP request arrives until a response is generated.
1. Incoming Request
When An HTTP request is made by Browser or fetch(), server fetch handler receives a Request object.
%%{init: {'theme':'neutral'}}%%
flowchart LR
A1["`fetch(request)`"] --> A2["`server.fetch(request)`"]
click A2 "/guide/api/h3#h3fetch"[!TIP]
💥 Srvx provides unifiedserver.fetchinterface and adds Node.js compatibility.
2. Accept Request
H3 Initializes an H3Event instance from incoming request, calls onRequest global hook and finally H3.handler with the initialized event.
%%{init: {'theme':'neutral'}}%%
flowchart LR
B1["`new H3Event(request)`"] --> B2["`onRequest(event)`"] --> B3["`h3.handler(event)`"]
click B1 "/guide/api/h3event"
click B2 "/guide/api/h3#global-hooks"
click B3 "/guide/api/h3#apphandler"3. Dispatch Request
H3 matches route based on request.url and request.method, calls global middleware and finally matched route handler function with event.
%%{init: {'theme':'neutral'}}%%
sequenceDiagram
participant MiddlewareA as Middleware1(event, next)
participant MiddlewareB as Middleware2(event, next)
participant Route as RouteHandler(event)
MiddlewareA->>+MiddlewareB: await next()
MiddlewareB->>+Route: await next()
Route-->>-MiddlewareB: rawBody
MiddlewareB-->>-MiddlewareA: rawBody[!TIP]
🚀 Internally, H3 uses srvxFastURLinstead ofnew URL(req.url).pathname.
4. Send Response
H3 converts returned value and prepared headers into a Response, calls onResponse global hook and finally returns response back to the server fetch handler.
%%{init: {'theme':'neutral'}}%%
flowchart LR
D1["Returned Value => Response"] --> D2["`onResponse(response)`"] --> D3["Response"]
click D1 "/guide/basics/response"
click D2 "/guide/api/h3#global-hooks"File: docs/1.guide/1.basics/2.routing.md
icon: solar:routing-bold
Routing
Each request is matched to one (most specific) route handler.
Adding Routes
You can register route handlers to H3 instance using H3.on, H3.[method], or H3.all.
[!TIP]
Router is powered by 🌳 Rou3, an ultra-fast and tiny route matcher engine.
Example: Register a route to match requests to the /hello endpoint with HTTP GET method.
Using
H3.[method]jsapp.get("/hello", () => "Hello world!");Using
H3.onjsapp.on("GET", "/hello", () => "Hello world!");
You can register multiple event handlers for the same route with different methods:
app
.get("/hello", () => "GET Hello world!")
.post("/hello", () => "POST Hello world!")
.all("/hello", () => "Any other method!");You can also use H3.all method to register a route accepting any HTTP method:
app.all("/hello", (event) => `This is a ${event.req.method} request!`);HEAD Requests
Following RFC 9110, HEAD requests automatically match the corresponding GET route and run its handler, but the response body is omitted (only the headers and status are sent). You don't need to register a separate HEAD handler:
app.get("/hello", () => "Hello world!");
// HEAD /hello → 200 with the same headers as GET, but an empty bodyRegister an explicit HEAD handler when you want to override this — for example, to skip computing the body:
app.head("/hello", (event) => {
event.res.headers.set("content-length", "12");
return null;
});An explicit head() route always takes precedence over the automatic GET fallback.
HTTP `QUERY` Method
H3 supports the HTTP QUERY method (RFC 10008) as a first-class method. QUERY is like GET — safe, idempotent, and cacheable — but carries a request body (with a Content-Type), closing the long-standing "GET with a body" gap. It's ideal for complex read operations where filters don't fit in a URL.
Register a QUERY handler with app.query() (or app.on("QUERY", …)) and read the request body as usual:
import { readBody } from "h3";
app.query("/search", async (event) => {
const criteria = await readBody(event); // read the query body
return runSearch(criteria);
});Because QUERY carries an attacker-controllable body, body-size limits apply just like POST.
Two utilities help implement the RFC:
requireContentType(event, acceptedTypes)— assert the requestContent-Type(400/415/422).appendAcceptQuery(event, mediaTypes)— advertise accepted query formats via theAccept-Queryresponse header.
[!NOTE]
QUERYis treated likeGETfor conditional caching (304responses viahandleCacheHeaders), andproxyforwards it with its body. UnlikeGET,QUERYis not CORS-safelisted, so browsers send a preflight — if you pass an explicitmethodsallowlist tohandleCors, include"QUERY".
::read-more{to="/examples/handle-query"}
See the HTTP QUERY method example for a runnable /books resource that validates the Content-Type and advertises a cacheable GET alternative.
::
Route Patterns
A route pattern is a pathname, not a URL: the same shape as event.url.pathname, plus rou3 syntax. H3.on, H3.[method], H3.all, H3.use(route, ...), H3.mount and removeRoute normalize it identically, so a middleware registered with the same string as a route always guards that route.
Normalization rules:
- A leading
/is added when missing ("hello"→/hello). - A URL is rejected (
app.get("http://example.com/admin")throws). An authority is never silently dropped://adminregisters as the two-segment path//admin, not as/. .and..segments resolve exactly as the URL parser resolves them in a request path (/admin/../admin→/admin).- Characters that a request pathname always carries percent-encoded are encoded: space, non-ASCII, control characters,
",#,<,>and`. Soapp.get("/café")registers/caf%C3%A9— what the browser actually sends. - Needless escapes are decoded to the literal that the request pathname is canonicalized to (
/%40handle→/@handle, see Security utils).
Characters that carry rou3 meaning are left exactly as written, including the escape \ (never valid in a request pathname). To match ?, {, } or ^ literally, write it percent-encoded:
app.get("/u/:id?", () => "optional param"); // rou3 syntax, kept as written
app.get("/x%3Fy", () => "literal ?"); // matches the path a client sends for /x?y[!NOTE]
Non-ASCII text mixes freely with dynamic syntax —app.get("/café/:id")registers/caf%C3%A9/:idand matches/café/42— with two exceptions, both from the encoded form reaching rou3's own syntax. A param name must be ASCII ([\w-]):/:naïvebecomes/:na%C3%AFve, which rou3 reads as a param namednafollowed by the literal%C3%AFve. And inside a(...)group, only literal text and alternation survive encoding ((café|thé)works); a character class does not, since[é]becomes[%C3%A9]— write the encoded alternation(?:%C3%A9)instead.
Dynamic Routes
You can define dynamic route parameters using : prefix:
// [GET] /hello/Bob => "Hello, Bob!"
app.get("/hello/:name", (event) => {
return `Hello, ${event.context.params.name}!`;
});Instead of named parameters, you can use * for unnamed optional parameters:
app.get("/hello/*", (event) => `Hello!`);Wildcard Routes
Adding /hello/:name route will match /hello/world or /hello/123. But it will not match /hello/foo/bar.
When you need to match multiple levels of sub routes, you can use ** prefix:
app.get("/hello/**", (event) => `Hello ${event.context.params._}!`);This will match /hello, /hello/world, /hello/123, /hello/world/123, etc.
[!NOTE]
Param_will store the full wildcard content as a single string.
Route Meta
You can define optional route meta when registering them, accessible from any middleware.
import { H3 } from "h3";
const app = new H3();
app.use((event) => {
console.log(event.context.matchedRoute?.meta); // { auth: true }
});
app.get("/", (event) => "Hi!", { meta: { auth: true } });::read-more{to="/guide/basics/handler#meta"}
It is also possible to add route meta when defining them using defineHandler object syntax.
::
File: docs/1.guide/1.basics/3.middleware.md
icon: mdi:middleware-outline
Middleware
Intercept request, response and errors using H3 middleware.
[!IMPORTANT]
We recommend using composable utilities whenever possible. Global middleware can complicate application logic, making it less predictable and harder to understand.
Global middleware run on each request before route handler and act as wrappers to intercept request, response and errors.
:read-more{to="/guide/basics/lifecycle#_3-dispatch-request" title="Request Lifecycle"}
You can register global middleware to app instance using the H3.use.
Example: Register a global middleware that logs every request.
app.use((event) => {
console.log(event);
});Example: Register a global middleware that matches certain requests.
app.use(
"/blog/**",
(event, next) => {
console.log("[alert] POST request on /blog paths!");
},
{
method: "POST",
// match: (event) => event.req.method === "POST",
},
);You can register middleware with next argument to intercept return values of next middleware and handler.
app.use(async (event, next) => {
const rawBody = await next();
// [intercept response]
return rawBody;
});Example below, always responds with Middleware 1.
app
.use(() => "Middleware 1")
.use(() => "Middleware 2")
.get("/", "Hello");[!IMPORTANT]
If middleware returns a value other thanundefinedor the result ofnext(), it immediately intercepts request handling and sends a response.
When adding routes, you can register middleware that only run with them.
import { basicAuth } from "h3";
app.get(
"/secret",
(event) => {
/* ... */
},
{
middleware: [basicAuth({ password: "test" })],
},
);For convenience, H3 provides middleware factory functions onRequest, onResponse, and onError:
import { onRequest, onResponse, onError } from "h3";
app.use(
onRequest((event) => {
console.log(`[${event.req.method}] ${event.url.pathname}`);
}),
);
app.use(
onResponse((response, event) => {
console.log(`[${event.req.method}] ${event.url.pathname} ~>`, response.status);
}),
);
app.use(
onError((error, event) => {
console.log(`[${event.req.method}] ${event.url.pathname} !! ${error.message}`);
}),
);File: docs/1.guide/1.basics/4.handler.md
icon: mdi:function
Event Handlers
An event handler is a function that receives an H3Event and returns a response.
You can define typed event handlers using defineHandler.
import { H3, defineHandler } from "h3";
const app = new H3();
const handler = defineHandler((event) => "Response");
app.get("/", handler);[!NOTE]
UsingdefineHandleris optional.
You can instead, simply use a function that accepts anH3Eventand returns a response.
The callback function can be sync or async:
defineHandler(async (event) => "Response");Object Syntax
middleware
You can optionally register some middleware to run with event handler to intercept request, response or errors.
import { basicAuth } from "h3";
defineHandler({
middleware: [basicAuth({ password: "test" })],
handler: (event) => "Hi!",
});:read-more{to="/guide/basics/response" title="Response Handling"}
:read-more{to="/guide/api/h3event" }
meta
You can define optional route meta attached to handlers, and access them from any other middleware.
import { H3, defineHandler } from "h3";
const app = new H3();
app.use((event) => {
console.log(event.context.matchedRoute?.meta); // { tag: "admin" }
});
app.get("/admin/**", defineHandler({
meta: { tag: "admin" },
handler: (event) => "Hi!",
})::read-more{to="/guide/basics/routing#route-meta"}
It is also possible to add route meta when registering them to app instance.
::
Handler `.fetch`
Event handlers defined with defineHandler, can act as a web handler without even using H3 class.
const handler = defineHandler(async (event) => `Request: ${event.req.url}`);
const response = await handler.fetch("http://localhost/");
console.log(response, await response.text());Lazy Handlers
You can define lazy event handlers using defineLazyEventHandler. This allow you to define some one-time logic that will be executed only once when the first request matching the route is received.
A lazy event handler must return an event handler.
import { defineLazyEventHandler } from "h3";
defineLazyEventHandler(async () => {
await initSomething(); // Will be executed only once
return (event) => {
return "Response";
};
});This is useful to define some one-time logic such as configuration, class initialization, heavy computation, etc.
Another use-case is lazy loading route chunks:
import { H3, defineLazyEventHandler } from "h3";
const app = new H3();
app.all(
"/route",
defineLazyEventHandler(() => import("./route.mjs").then((mod) => mod.default)),
);import { defineHandler } from "h3";
export default defineHandler((event) => "Hello!");Converting to Handler
There are situations that you might want to convert an event handler or utility made for Node.js or another framework to H3.
There are built-in utils to do this.
From Web Handlers
Request handlers with Request => Response signuture can be converted into H3 event handlers using fromWebHandler utility or H3.mount.
import { H3, fromWebHandler } from "h3";
export const app = new H3();
const webHandler = (request) => new Response("👋 Hello!");
// Using fromWebHandler utility
app.all("/web", fromWebHandler(webHandler));
// Using simple wrapper
app.all("/web", (event) => webHandler(event.req));
// Using app.mount
app.mount("/web", webHandler);From Node.js Handlers
If you have a legacy request handler with (req, res) => {} syntax made for Node.js, you can use fromNodeHandler to convert it to an h3 event handler.
[!IMPORTANT]
Node.js event handlers can only run within Node.js server runtime!
import { H3, fromNodeHandler } from "h3";
// Force using Node.js compatibility (also works with Bun and Deno)
import { serve } from "h3/node";
export const app = new H3();
const nodeHandler = (req, res) => {
res.end("Node handlers work!");
};
app.get("/web", fromNodeHandler(nodeHandler));File: docs/1.guide/1.basics/5.response.md
icon: tabler:json
Sending Response
H3 automatically converts any returned value into a web response.
Values returned from Event Handlers are automatically converted to a web Response by H3.
Example: Simple event handler function.
const handler = defineHandler((event) => ({ hello: "world" }));H3 smartly converts handler into:
const handler = (event) =>
new Response(JSON.stringify({ hello: "world" }), {
headers: {
"content-type": "application/json;charset=UTF-8",
},
});[!TIP]
🚀 H3 uses srvxFastResponseinternally to optimize performances in Node.js runtime.
If the returned value from event handler is a Promise or from an async function, H3 will wait for it to resolve before sending the response.
If an error is thrown, H3 automatically handles it with error handler.
:read-more{to="/guide/basics/error" title="Error Handling"}
Preparing Response
Before returning a response in main handler, you can prepare response headers and status using event.res.
defineHandler((event) => {
event.res.status = 200;
event.res.statusText = "OK";
event.res.headers.set("Content-Type", "text/html");
return "<h1>Hello, World</h1>";
});[!NOTE]
If a full Response value is returned, prepared status is discarded and headers will be merged/overridden. For performance reasons, it is best to only set headers only from final Response in this case.
[!NOTE]
If an Error happens, prepared status and headers will be discarded. The recommended way to include headers in error responses is vianew HTTPError({ headers }). As a last resort for headers that need to be set implicitly before the error is known (e.g., CORS), you can useevent.res.errHeaders— these will be merged into error responses automatically.
Response Types
H3 smartly converts JavaScript values into web Response.
JSON Serializable Value
Returning a JSON serializable value (object, array, number or boolean), it will be stringified using JSON.stringify() and sent with default application/json content-type.
Example:
app.get("/", (event) => ({ hello: "world" }));[!TIP]
Returned objects with.toJSON()property can customize serialization behavior. Check MDN docs for more info.
String
Returning a string value, sends it as plain text body.
[!NOTE]
If not settingcontent-typeheader, it can default totext/plain;charset=UTF-8.
Example: Send HTML response.
app.get("/", (event) => {
event.res.headers.set("Content-Type", "text/html;charset=UTF-8");
return "<h1>hello world</h1>";
});You can also use html utility as shortcut. Interpolated values in the tagged template are automatically HTML-escaped to help prevent XSS.
import { html, raw } from "h3";
// Tagged template: interpolations are escaped
app.get("/hello/:name", (event) => html`<h1>hello ${event.context.params.name}</h1>`);
// Trusted markup: sent as-is
app.get("/", () => html(raw("<h1>hello world</h1>")));[!IMPORTANT]
Callinghtml()with a plain string escapes the whole string (a warning is logged if escaping changed it). Use the tagged template for dynamic values, or wrap trusted markup withraw().
`Response`
Returning a web Response, sends-it as final response.
Example:
app.get("/", (event) => new Response("Hello, world!", { headers: { "x-powered-by": "H3" } }));[!IMPORTANT]
When sending aResponse, any prepared headers that set before, will be merged as default headers.event.res.{status,statusText}will be ignored. For performance reasons, it is best to only set headers only from finalResponse.If the returned
Responsehas an error status (>= 400), prepared headers are discarded — same as for a thrown error — and onlyevent.res.errHeadersare merged. Success and redirect responses (< 400) receive all prepared headers, sosetCookie()before returning a302works as expected.
`ReadableStream` or `Readable`
Returning a ReadableStream or Node.js Readable sends it as stream.
`ArrayBuffer` or `Uint8Array` or `Buffer`
Send binary ArrayBuffer, Uint8Array or node Buffer.
content-length header will be automatically set.
`Blob`
Send a Blob as stream.
Content-type and Content-Length headers will be automatically set.
`File`
Send a File as stream.
Content-type, Content-Length and Content-Disposition headers will be automatically set.
Special Types
Some less commonly possible values for response types.
`null` or `undefined`
Sends a response with empty body.
[!TIP]
If there is noreturnstatement in event handler, it is same asreturn undefined.
`Error`
Retuning an Error instance will send it.
[!IMPORTANT]
It is better tothrowerrors instead of returning them. This allows proper propagation from any nested utility.
:read-more{to="/guide/basics/error" title="Error Handling"}
`BigInt`
Value will be sent as stringified version of BigInt number.
[!NOTE]
Returning a JSON object, does not allows BigInt serialization. You need to implement.toJSON. Check MDN docs for more info.
`Symbol` or `Function`
Returning Symbol or Function has undetermined behavior. Currently, H3 sends a string-like representation of unknown Symbols and Functions but this behavior might be changed to throw an error in the future versions.
There are some internal known Symbols H3 internally uses:
Symbol.for("h3.notFound"): Indicate no route is found to throw a 404 error.Symbol.for("h3.handled"): Indicate request is somehow handled and H3 should not continue (Node.js specific).
File: docs/1.guide/1.basics/6.error.md
icon: tabler:error-404
Error Handling
Send errors by throwing an HTTPError.
H3 captures all possible errors during request lifecycle.
`HTTPError`
You can create and throw HTTP errors using HTTPError with different syntaxes.
import { HTTPError } from "h3";
app.get("/error", (event) => {
// Using message and details
throw new HTTPError("Invalid user input", { status: 400 });
// Using HTTPError.status(code)
throw HTTPError.status(400, "Bad Request");
// Using single object
throw new HTTPError({
status: 400,
statusText: "Bad Request",
message: "Invalid user input",
data: { field: "email" },
body: { date: new Date().toJSON() },
headers: {},
});
});This will end the request with 400 - Bad Request status code and the following JSON response:
{
"date": "2025-06-05T04:20:00.0Z",
"status": 400,
"statusText": "Bad Request",
"message": "Invalid user input",
"data": {
"field": "email"
}
}`HTTPError` Fields
status: HTTP status code in the range 200–599.statusText: HTTP status text to be sent in the response header.message: Error message to be included in the JSON body.data: Additional data to be attached under thedatakey in the error JSON body.body: Additional top-level properties to be attached in the error JSON body.headers: Additional HTTP headers to be sent in the error response.cause: The original error object that caused this error, useful for tracing and debugging.unhandled: Indicates whether the error was thrown for unknown reasons. See Unhandled Errors.
[!TIP]
The recommended way to include headers in error responses is to usenew HTTPError({ headers }):jsthrow new HTTPError({ status: 400, message: "Invalid input", headers: { "x-request-id": requestId }, });When an error is thrown, any prepared headers set via
event.res.headersare not included in the error response. As a last resort for headers that need to be set implicitly before the error is known (e.g., CORS headers), you can useevent.res.errHeaders. Built-in utilities likehandleCorsautomatically set both.
[!IMPORTANT]
ErrorstatusTextshould be short (max 512 to 1024 characters) and only include tab, spaces or visible ASCII characters and extended characters (byte value 128–255). Prefermessagein JSON body for extended message.
Unhandled Errors
Any error that occurs during calling request lifecycle without using HTTPError will be processed as an unhandled error.
app.get("/error", (event) => {
// This will cause an unhandled error.
throw new Error("Something went wrong");
});[!TIP]
For enhanced security, H3 hides certain fields of unhandled errors (data,body,stackandmessage) in JSON response.
Catching Errors
Using global onError hook:
import { H3, onError } from "h3";
// Globally handling errors
const app = new H3({
onError: (error) => {
console.error(error);
},
});Using onError middleware to catch errors.
import { onError } from "h3";
// Handling errors using middleware
app.use(
onError((error, event) => {
console.error(error);
}),
);[!TIP]
When using nested apps, global hooks of sub-apps will not be called. Therefore it is better to useonErrormiddleware.
File: docs/1.guide/1.basics/7.nested-apps.md
icon: material-symbols-light:layers-outline
Nested Apps
H3 has a native
mountmethod for adding nested sub-apps to the main instance.
Typically, H3 projects consist of several Event Handlers defined in one or multiple files (or even lazy loaded for faster startup times).
It is sometimes more convenient to combine multiple H3 instances or even use another HTTP framework used by a different team and mount it to the main app instance. H3 provides a native .mount method to facilitate this.
Nested H3 Apps
H3 natively allows mounting sub-apps. When mounted, sub-app routes and middleware are merged with the base url prefix into the main app instance.
import { H3, serve } from "h3";
const nestedApp = new H3()
.use((event) => {
event.res.headers.set("x-api", "1");
})
.get("/**:slug", (event) => ({
pathname: event.url.pathname,
slug: event.context.params?.slug,
}));
const app = new H3().mount("/api", nestedApp);In the example above, when fetching the /api/test URL, pathname will be /api/test (the real path), and slug will be /test (wildcard param).
[!NOTE]
Global config and hooks won't be inherited from the nested app. Consider always setting them from the main app.
Nested Web Standard Apps
Mount a .fetch compatible server instance like Hono or Elysia under the base URL.
[!NOTE]
Base prefix will be removed fromrequest.urlpassed to the mounted app.
import { H3 } from "h3";
import { Hono } from "hono";
import { Elysia } from "elysia";
const app = new H3()
.mount(
"/elysia",
new Elysia().get("/test", () => "Hello Elysia!"),
)
.mount(
"/hono",
new Hono().get("/test", (c) => c.text("Hello Hono!")),
);File: docs/1.guide/900.api/1.h3.md
icon: material-symbols:bolt-rounded
H3
H3 class is the core of server.
You can create a new H3 app instance using new H3():
import { H3 } from "h3";
const app = new H3({/* optional config */});`H3` Methods
`H3.request`
A fetch-compatible function allowing to fetch app routes.
const response = await app.request("/");
console.log(response, await response.text());`H3.fetch`
Similar to H3.request but only accepts one (req: Request) argument for cross runtime compatibility.
`H3.on`
Register route handler for specific HTTP method.
const app = new H3().on("GET", "/", () => "OK");:read-more{to="/guide/basics/routing" title="Routing"}
`H3.[method]`
Register route handler for specific HTTP method (shortcut for app.on(method, ...)).
const app = new H3().get("/", () => "OK");`H3.all`
Register route handler for all HTTP methods.
const app = new H3().all("/", () => "OK");`H3.use`
Register a global middleware.
const app = new H3()
.use((event) => {
console.log(`request: ${event.req.url}`);
})
.all("/", () => "OK");:read-more{to="/guide/basics/middleware" title="Middleware"}
`H3.register`
Register a H3 plugin to extend app.
:read-more{to="/guide/advanced/plugins" title="Plugins"}
`H3.handler`
An H3 event handler useful to compose multiple H3 app instances.
Example: Nested apps.
import { H3, serve, redirect, withBase } from "h3";
const nestedApp = new H3().get("/test", () => "/test (sub app)");
const app = new H3()
.get("/", (event) => redirect(event, "/api/test"))
.all("/api/**", withBase("/api", nestedApp.handler));
serve(app);`H3.mount`
Using .mount method, you can register a sub-app with prefix.
:read-more{to="/guide/basics/nested-apps" title="Nested Apps"}
`H3` Options
You can pass global app configuration when initializing an app.
Supported options:
debug: Displays debugging stack traces in HTTP responses (potentially dangerous for production!).silent: When enabled, console errors for unhandled exceptions will not be displayed.allowMalformedURL: When enabled, requests with a malformed percent-encoded URL path (e.g./foo%,/%ZZ) are allowed through with the raw pathname instead of being rejected with a400 Bad Request(the default).plugins: (see plugins for more information)
[!IMPORTANT]
Enablingdebugoption, sends important stuff like stack traces in error responses. Only enable during development.
Global Hooks
When initializing an H3 app, you can register global hooks:
onErroronRequestonResponse
These hooks are called for every request and can be used to add global logic to your app such as logging, error handling, etc.
const app = new H3({
onRequest: (event) => {
console.log("Request:", event.req.url);
},
onResponse: (response, event) => {
console.log("Response:", event.url.pathname, response.status);
},
onError: (error, event) => {
console.error(error);
},
});[!IMPORTANT]
Global hooks only run from main H3 app and not sub-apps. Use middleware for more flexibility.
`H3` Properties
`H3.config`
Global H3 instance config.
File: docs/1.guide/900.api/2.h3event.md
icon: material-symbols:data-object-rounded
H3Event
H3Event, carries incoming request, prepared response and context.
With each HTTP request, H3 internally creates an H3Event object and passes it though event handlers until sending the response.
:read-more{to="/guide/basics/lifecycle" title="Request Lifecycle"}
An event is passed through all the lifecycle hooks and composable utils to use it as context.
Example:
app.get("/", async (event) => {
// Log HTTP request
console.log(`[${event.req.method}] ${event.req.url}`);
// Parsed URL and query params
const searchParams = event.url.searchParams;
// Try to read request JSON body
const jsonBody = await event.req.json().catch(() => {});
return "OK";
});`H3Event` Methods
`H3Event.waitUntil`
Tell the runtime about an ongoing operation that shouldn't close until the promise resolves.
import { logRequest } from "./tracing.mjs";
app.get("/", (event) => {
request.waitUntil(logRequest(request));
return "OK";
});export async function logRequest(request) {
await fetch("https://telemetry.example.com", {
method: "POST",
body: JSON.stringify({
method: request.method,
url: request.url,
ip: request.ip,
}),
});
}[!TIP]
To release per-request resources (timers, upstream connections, file handles) once the event is fully over — on every runtime — use theonDispose(event, cb)utility.
`H3Event` Properties
`H3Event.app?`
Access to the H3 application instance.
`H3Event.context`
The context is an object that contains arbitrary information about the request.
You can store your custom properties inside event.context to share across utils.
Known context keys:
context.params: Matched router parameters.middlewareParams: Matched middleware parametersmatchedRoute: Matched router route object.sessions: Cached session data.basicAuth: Basic authentication data.
`H3Event.req`
Incoming HTTP request info based on native Web Request with additional runtime addons (see srvx docs).
app.get("/", async (event) => {
const url = event.req.url;
const method = event.req.method;
const headers = event.req.headers;
// (note: you can consume body only once with either of this)
const bodyStream = await event.req.body;
const textBody = await event.req.text();
const jsonBody = await event.req.json();
const formDataBody = await event.req.formData();
return "OK";
});`H3Event.url`
Access to the full parsed request URL.
app.get("/", (event) => {
const { pathname, search, searchParams } = event.url;
return "OK";
});Pathname encoding
event.url.pathname is the path in its wire encoding, with one exception: an escape that is needlessly there is dropped.
An escape is needless when every decoding consumer — a proxy, a filesystem lookup, a handler calling decodeURIComponent — reads it as its literal, while H3's matchers compare the two as different strings. Left alone, that gap lets /%61dmin slip past an /admin guard and still reach the /admin route, or reach a catch-all handler that decodes it downstream. So H3 decodes exactly those escapes, once, before routing. Nothing else is touched:
| Request | event.url.pathname |
Why |
|---|---|---|
/%61dmin |
/admin |
Unreserved escape, needlessly encoded |
/a%2eb |
/a.b |
Unreserved escape, needlessly encoded |
/a%21b |
/a!b |
Decodes downstream, needlessly encoded |
/%40handle |
/@handle |
Decodes downstream, needlessly encoded |
/a/%2e%2e/b |
/b |
The URL parser resolved the dot segment first |
/x%2fy |
/x%2fy |
Separator, must stay encoded |
/x%5cy |
/x%5cy |
Separator, must stay encoded |
/100%25 |
/100%25 |
Decoding it would expose a nested escape |
/a%20b |
/a%20b |
The URL serializer re-encodes a space anyway |
/caf%C3%A9 |
/caf%C3%A9 |
The URL serializer re-encodes non-ASCII anyway |
The decoded set is every escape whose character survives WHATWG path serialization unchanged, minus %2f and %25: the RFC 3986 §2.3 unreserved set (ALPHA / DIGIT / - / . / _ / ~), which is equivalent to its literal per §6.2.2.2, plus !, $, &, ', (, ), *, +, ,, :, ;, =, @, [, ] and |. Anything else is left alone because decoding it cannot survive the round trip (%20, %5E, %7B, non-ASCII), would change how many segments the path has (%2f, %5c), would delete the character outright (%09 and the other C0 controls), or would expose a nested escape (%25).
That is wider than decodeURI, which preserves all of RFC 3986's reserved set (; / ? : @ & = + $ ,) — of which only / is structural in an already-parsed path. Routes like /@handle or /resource:action are ordinary, so a guard protecting one must not be walkable past by its escaped spelling (/%40handle).
Route matching, use() matchers and your own event.url.pathname checks therefore all compare one and the same string. event.req.url always keeps the original wire encoding, so for a non-canonical path the two disagree: read the path from event.url, and never re-derive it from event.req.url — slicing one by an offset taken from the other is how mount prefixes and proxy targets desync.
Canonicalization happens in the H3Event constructor, so it covers every event, including one built by mockEvent() or a standalone handler.fetch().
Every escape that survives is opaque — treat it that way:
[!WARNING]
Never decodeevent.url.pathnameyourself. Decoding can reintroduce a/or..that routing and middleware never saw, which is a path traversal vector when the value reaches a filesystem or an upstream URL. To read a route param in decoded form, usegetRouterParams(event, { decode: true }), which decodes everything else but keeps encoded separators encoded. To canonicalize a path for a scope check, useresolveDotSegments.
A route param therefore can never contain a path separator the router did not match on: %2f and %5c stay encoded, so /a%2fb and /a%5cb are one segment (matching the route /:id, not /a/:id).
Requests with malformed percent-encoding (such as /foo% or /%ZZ) have no canonical form to decode to and are rejected with a 400 Bad Request before any handler runs. Set the allowMalformedURL app option to receive the raw pathname instead.
`H3Event.res`
Prepared HTTP response status and headers.
app.get("/", (event) => {
event.res.status = 200;
event.res.statusText = "OK";
event.res.headers.set("x-test", "works");
return "OK";
});:read-more{to="/guide/basics/response#preparing-response" title="Preparing Response"}