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]app.get("/hello", () => "Hello world!");Using
H3.onapp.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
QUERY is treated like GET for conditional caching (304 responses via handleCacheHeaders), and proxy forwards it with its body. Unlike GET, QUERY is not CORS-safelisted, so browsers send a preflight — if you pass an explicit methods allowlist to handleCors, include "QUERY".
#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?yNote
Non-ASCII text mixes freely with dynamic syntax — app.get("/café/:id") registers /caf%C3%A9/:id and matches /café/42 — with two exceptions, both from the encoded form reaching rou3's own syntax. A param name must be ASCII ([\w-]): /:naïve becomes /:na%C3%AFve, which rou3 reads as a param named na followed 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 } });