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

    app.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 body

Register 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 GETsafe, 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:

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

Read more in Examples > Handle 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: //admin registers 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 `. So app.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/: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 } });
Read more in Guide > Basics > Handler#meta.

H3  Universal, Tiny, and Fast Servers.