Route Rules
Add headers, redirects, CORS, caching, and proxying to groups of routes with one configuration object.
Route rules let you configure behavior that often lives in a CDN or reverse proxy. Instead of writing separate middleware for each concern, describe what should happen for each URL pattern:
routeRules({
"/old/**": { redirect: "/new/**" },
"/assets/**": { headers: { "cache-control": "s-maxage=31536000" } },
"/api/**": { cors: true },
});Add routeRules() as global middleware. For each request, it:
event.context.routeRules.Tip
Route rules run inside your app, so they behave consistently across all runtimes supported by h3. You can still put a CDN in front of the app. Because the configuration is plain data, frameworks and build tools can also use it. See Data-Only Rules and the compiler.
Import the core feature from h3/rules. Caching and proxying have optional handlers in h3/rules/cache and h3/rules/proxy. Build-time code generation is available from h3/rules/compiler.
#Quick Start
import { H3, serve } from "h3";
import { routeRules } from "h3/rules";
import { cache } from "h3/rules/cache"; // Needed for `cache` and `swr` (requires ocache)
import { proxy } from "h3/rules/proxy"; // Needed for `proxy`
const app = new H3();
app.use(
routeRules(
{
"/blog/**": { swr: 60 },
"/old/**": { redirect: { to: "/new/**", status: 301 } },
"/api/proxy/**": { proxy: "https://example.com/**" },
"/assets/**": { headers: { "cache-control": "s-maxage=31536000" } },
"/api/**": { cors: true },
"GET /api/cached/**": { swr: 60 }, // applies to GET only
},
{ handlers: { cache, proxy } },
),
);
serve(app);#Built-in Rules
Start with headers, redirect, or cors: these work without extra dependencies. Register the cache handler for cache and swr, or the proxy handler for proxy.
| Rule | What it does |
|---|---|
headers | Set response headers. |
redirect | Send a server-side redirect. |
cors | Handle CORS with handleCors. Preflight (OPTIONS) requests are answered directly. |
cache | Cache the matched route handler's response. Opt-in, see Caching. |
swr | Shortcut for cache: { swr: true, maxAge?: number }. |
proxy | Forward the request to another origin or an in-app path. Opt-in, see Proxying. |
Note
Route rules intentionally do not include an auth rule. Authentication needs executable logic that can check your user store, so it belongs in middleware. Use basicAuth or your own middleware. If you create an auth rule with a custom handler, read Security first, especially the restricting flag.
#headers
Sets headers on the final response. This happens after cache, redirect, and proxy, so a cache-control value here overrides one produced by the cache handler.
routeRules({
"/assets/**": { headers: { "cache-control": "s-maxage=31536000" } },
});#redirect
Pass a string to use the default 307 status. Pass { to, status } to choose the status:
routeRules({
"/old/**": { redirect: "/new/**" }, // /old/a?x=1 → /new/a?x=1 (307)
"/search": { redirect: "https://example.com/s?lang=en" }, // /search?q=h3 → …/s?lang=en&q=h3
"/legacy": { redirect: { to: "/", status: 301 } },
});Redirects and proxies share two useful behaviors:
- Wildcard tails: If both the pattern and target end in
/**, h3 appends the matched part of the path to the target. - Query forwarding: h3 preserves the request query string, including duplicate keys and encoding. If the target already has a query, the request query is appended to it.
#cors
Pass true to use permissive defaults. Pass a CorsOptions object to configure an origin allowlist, credentials, maxAge, and other options:
routeRules({
"/api/**": { cors: { origin: ["https://example.com"], credentials: true } },
});Note
Internally, cors: true becomes an empty options object ({}). On a more specific pattern, it inherits options from broader matching patterns instead of resetting them to permissive defaults. Use cors: false to remove inherited CORS behavior.
#cache and swr
Caches the response from the matched route handler. You must register a cache handler first. See Caching.
swr: 60 is shorthand for cache: { swr: true, maxAge: 60 }. A value of 0 is valid. Use swr: false to remove an inherited cache rule.
#proxy
Forward matching requests elsewhere. This rule needs the opt-in handler from h3/rules/proxy — see Proxying.
#How Matching Works
Route rules use 🌳 Rou3, the same engine as routing. Patterns are matched against event.url.pathname.
Route matching and rule matching differ in one important way: a route uses only its most specific match, but route rules apply every matching pattern. H3 merges matches from least specific to most specific. Object options are shallow-merged, with the more specific values winning. Primitive values and other non-object values are replaced completely.
routeRules({
"/**": { headers: { "x-app": "demo" } },
"/api/**": { headers: { "x-api": "1" } },
});
// GET /api/users → x-app: demo, x-api: 1#Resetting a Rule
Set a rule to false on a more specific pattern to turn off inherited behavior for that part of your app:
routeRules({
"/api/**": { cors: { origin: ["https://example.com"] } },
"/api/public/**": { cors: false }, // no CORS handling under /api/public
});#Method-Scoped Rules
Add an HTTP method before a pattern when a rule should apply only to that method. Patterns without a method apply to every method. Method-specific rules merge last, so they can override general rules. Method names are case-insensitive.
routeRules({
"/api/**": { headers: { "x-api": "1" } }, // any method
"GET /api/**": { swr: 60 }, // GET only
});H3 groups equivalent route patterns together. For example, /users/*, /users/:id, and /users/:userId describe the same route group. A general rule using one spelling can therefore merge with a method-specific rule using another spelling.
Note
If equivalent spellings have different specificity, such as /a/* and /a/:id, the more general pattern resolves last and wins. This applies whether or not the rules are method-scoped. To avoid surprising results, use one spelling consistently.
#Reading Matched Rules
Handlers and middleware can read the final merged configuration from event.context.routeRules. Entries are keyed by rule name. More specific values are already merged, and shortcuts such as swr are already expanded:
app.get("/blog/:slug", (event) => {
const rules = event.context.routeRules;
rules?.cache; // { swr: true, maxAge: 60 }
rules?.redirect?.to; // "/new"
rules?.headers?.["x-a"]; // "1"
});Important
Match results are memoized by default. This means the same result object can be shared by multiple requests. Always treat event.context.routeRules and its nested values as read-only.
The context contains the merged values, but not the patterns they came from. Framework integrations that need the contributing pattern and its parameters can use a matcher directly:
const { routeRules, matchedRules } = matcher("GET", "/blog/post");
routeRules.cache; // { swr: true, maxAge: 60 } — same object the context gets
matchedRules.cache?.route; // "/blog/**"
matchedRules.cache?.params; // rou3 params of the contributing patternsYou can register routeRules() more than once. Each instance merges its results over earlier instances, and the later instance wins for the same rule name. This lets a framework provide defaults while an app adds or overrides its own rules.
#Data-Only Rules
A rule without a registered handler is data-only. It is still matched, merged, and exposed through event.context.routeRules, but it does not change the response at runtime. Frameworks and build tools can use data-only keys such as prerender, isr, or custom metadata.
routeRules({
"/docs/**": { prerender: true },
});
// event.context.routeRules.prerender → trueDeclare custom data-only keys to make them type-safe. See TypeScript.
#Caching
The core h3/rules package does not include a cache implementation. To use cache or swr, register a cache handler. H3 throws while creating the matcher if these rules are present without a handler.
H3 provides an optional handler backed by ocache in h3/rules/cache. Install ocache alongside h3; it is an optional peer dependency. Apps that do not use caching will not include ocache in their bundles.
import { routeRules } from "h3/rules";
import { cache } from "h3/rules/cache";
// default: in-memory storage
app.use(routeRules({ "/blog/**": { swr: 60 } }, { handlers: { cache } }));The default handler uses in-memory storage. Create your own handler instance to change the storage or default options:
import { createOcacheRuleHandler } from "h3/rules/cache";
app.use(
routeRules(rules, {
handlers: {
cache: createOcacheRuleHandler({
storage: myStorage, // ocache storage instance (or a factory), shared by every rule
defaults: { staleMaxAge: 60 }, // ocache defaults incl. hooks (rule options win)
}),
},
}),
);#How Entries Are Keyed
The cache rule wraps the matched route handler, so it only runs when h3 finds a route. By default, entries use the "h3/route-rules" group and the name <handlerScope>:<method>:<rulePattern>:<matchedRoute>:
- The scope is unique to both the handler instance and the matched route handler. Two apps or matchers cannot read or write each other's entries, even if they share the module-level
cacheexport. - The method prevents a body-less
HEADresponse from being stored as theGETresponse.
You can override group and name as normal cache rule options. Be careful: an explicit name replaces the entire default name, including its isolation.
The generated scope changes between processes. With persistent storage, each process therefore creates its own entries, and workers do not share them. For stable keys, pass createOcacheRuleHandler({ id: "my-app" }), but only use that instance for one app.
Note
Normally, ocache gives each cached handler its own storage instance. createOcacheRuleHandler instead shares one store across all of its rules. This is either the storage you provide or a memory store created on the first cached request. The result is one bounded cache for all routes in the app. Two instances with the same id also share that default store because their keys match.
#What Reaches a Cached Handler
H3 only passes request data to the cached handler when that data is represented in the cache key. This prevents cached responses from depending on values that do not vary the entry:
- Query strings are removed by default. The handler receives a URL without a query, and the query does not affect the key. Use
allowQuery: ["page", "q"]to allow specific names, orallowQuery: trueto include the full query string. - Headers are removed unless listed in
varies. See Credentials and Cookies for additional credential rules. Conditional, tracing, and request-ID headers are also removed; read them in middleware outside the cache rule. - A response is returned but not stored if it uses a
Varyheader that the key does not cover, setsCache-Control: no-store,private, orno-cache, has a status other than200,203,301, or308, or is larger thanmaxBodySize.
Resolving an entry has a 30-second deadline, controlled by maxResolveTime. When the deadline expires, all waiters are rejected and the entry is evicted. The handler's event.req.signal is aborted too. Forward this signal if the handler makes an upstream fetch request.
#Credentials and Cookies
Important
H3 removes Cookie, Authorization, and Proxy-Authorization before calling a cached handler. These headers do not vary the automatically generated key. Without this protection, a response rendered for one user could be cached under an anonymous key, served to other users, and marked public, s-maxage=N for shared caches.
- Set
cache: { allowAuthorization: true }to pass the authorization credential to the handler. H3 hashes it into the key and adds it toVary, so each credential receives a separate entry. - Some runtimes may provide immutable headers and a request that cannot be rebuilt. If h3 cannot remove credentials safely, it returns a
500instead of caching a credentialed response under a credential-free key. - Headers listed in
variesremain visible to the cached handler. Each value gets its own key and is added to the response'sVaryheader. Credentials are an exception: listing them invariesdoes not forward them. UseallowAuthorizationinstead. - A handler's
Set-Cookieheader is sent only to the request that produced it and is never stored in the cache. This prevents one visitor's session cookie from being replayed to others.allowCookiesonly controls the request: it selects which cookie values reach the handler and vary the entry. Do not cache a route that must set a cookie on every response.
#Cache-Control Behavior
- H3 preserves a handler's
Cache-Controlheader when it containsprivateorno-store. Ocache also refuses to store the response. - H3 replaces any other handler-provided
Cache-Controlvalue with the rule's generatedpublic, max-age=N, s-maxage=N. Use aheadersrule when you need full control over the final value. - Set
sendCacheControl: falseto disable the generated header.
#Bring Your Own Cache
You can use another cache implementation instead of ocache. Create a handler with the core factory. defineCachedHandler receives the matched route handler and merged rule options, with group and name already filled in, and returns a cached wrapper. Frameworks such as Nitro can integrate here:
import { createCacheRuleHandler } from "h3/rules";
const cache = createCacheRuleHandler({
defineCachedHandler: (handler, opts) => myCachedHandler(handler, opts),
});The declarative options in RouteRuleConfig["cache"] use h3's ocache-compatible CacheRuleOptions schema. Implementation hooks such as getKey, shouldCache, and getMaxAge are not rule data. Pass them through the handler factory's defaults instead.
#Proxying
Proxying is also opt-in. The handler uses proxyRequest, so it is exported separately from h3/rules/proxy. Apps that do not proxy will not include it in their bundles. Register the handler explicitly; h3 throws while creating the matcher if a proxy rule has no handler:
import { routeRules } from "h3/rules";
import { proxy } from "h3/rules/proxy";
app.use(
routeRules({ "/api/proxy/**": { proxy: "https://example.com/**" } }, { handlers: { proxy } }),
);Proxy targets behave like redirect targets: h3 appends matching /** tails and forwards the query string.
Tip
You can keep cache or proxy as data-only rules. Pass handlers: { cache: undefined } or handlers: { proxy: undefined }. The rule will still be matched and exposed on the context, but it will not run any behavior.
#Execution Order
Rules that have runtime handlers run as middleware. Lower order numbers run first and wrap the rules inside them:
cors (-3) → [-2 free] → headers (-1) → custom rules (0) → redirect (1) → proxy (2) → cache (3) → route handlerIn practice:
- CORS can answer a preflight request before any other rule runs.
headerswraps every rule inside it, so its values override headers produced by caching or other inner rules.redirect,proxy, andcachecan finish the request without calling the next rule. Each has a separate order.cacheis innermost and dispatches the route handler.- Because
cachedispatches the route itself, it also ends the app's global middleware chain. See Cached Routes and Global Middleware. - Custom rules use order
0by default, so they run beforeredirect,proxy, andcache.
order is a number, and lower values run first. The -2 slot is intentionally free for a custom rule that must short-circuit before headers, redirect, proxy, and cache. Rules with the same order run by rule name. That order is deterministic but has no semantic meaning, so give any short-circuiting handler an explicit order. See Security for the security implications.
#Options
Pass these options as the second argument to routeRules(config, options):
| Option | Description |
|---|---|
baseURL | Prefix every rule pattern (trailing slash trimmed). |
handlers | Add or override rule handlers by name. undefined makes that rule data-only. |
memoize | Memoize match results per method + pathname. Enabled by default, see Memoization. |
preMerge | Resolve each pattern's subsumption chain at startup, see Pre-merging. |
#Custom Rule Handlers
Use a custom handler when you need runtime behavior that is not built in. A handler definition has the shape { handler, order? }:
handlerturns a matched rule into H3 middleware.ordercontrols when it runs. Lower values run first and the default is0. Built-in rules use-3through-1and1through3; see Execution Order.
The handler receives { options, route, params?, handler? }. options contains the merged value. route is the most specific contributing pattern, which is provenance not available on event.context.routeRules.
app.use(
routeRules(
{ "/x/**": { shout: "hello" } },
{
handlers: {
shout: {
handler: (matched) => (event) => {
event.res.headers.set("x-shout", String(matched.options).toUpperCase());
},
},
},
},
),
);Important
A custom handler that restricts access (an auth gate, a rate limit, an IP allowlist) must also set restricting: true — see Security for why.
#Performance
#Memoization
A given method + pathname always produces the same merged result, so routeRules() memoizes results by default. Repeated requests can skip pattern lookup, path canonicalization, merging, and middleware construction, reducing the hot path to a map lookup.
- The memoization map holds up to
1024entries by default and uses FIFO eviction. Dynamic paths therefore cannot grow it without limit. Change the cap withmemoize: { max }. - Use
memoize: falseto resolve every request again and create fresh result objects.
Lower-level matchers do not enable memoization automatically. Wrap a matcher with memoizeRouteRulesMatcher(matcher, opts?) to opt in. If you do not use it, bundlers can tree-shake the memoization code.
#Pre-merging
Set preMerge: true to merge each pattern's inheritance chain ahead of time, either when the matcher starts or at build time with the compiler. Each request then resolves only the most specific layer instead of merging every matched layer. Method-specific rules, general rules, false resets, and per-rule params behave the same as they do with normal per-request merging.
Pre-merging only works for rule sets whose relationships can be determined in advance. It cannot safely analyze partial overlaps such as /a/*/c and /a/b/*, where the most specific match is ambiguous, or patterns with regex parameters:
- The runtime matcher throws during startup.
- The compiler falls back safely. It logs a warning and uses normal compilation, so the generated matcher remains correct.
#Using Matchers Directly
Most apps should use routeRules(). Frameworks that need to resolve rules outside middleware can use the lower-level exports directly:
import { createRouteRulesMatcher, normalizeRouteRules, memoizeRouteRulesMatcher } from "h3/rules";
import { cache } from "h3/rules/cache";
const matcher = memoizeRouteRulesMatcher(
createRouteRulesMatcher(normalizeRouteRules(config), {
baseURL: "/base",
preMerge: true,
handlers: { cache },
}),
);
const { routeRules, routeRuleMiddleware } = matcher("GET", "/blog/post");normalizeRouteRules()expands shortcuts such asswr, normalizes string and boolean forms, and canonicalizes keys.createRouteRulesMatcher()expects these normalized rules, unlikerouteRules(). This keeps normalization code out of runtime bundles that do not need it.- A match returns
{ routeRules, matchedRules, routeRuleMiddleware }: the merged values placed on the event context, those values with pattern provenance, and the ordered middleware chain. mergeMatchedRouteRules()is the pure merge operation: it accepts matched layers and returns matched rules.ruleHandlersis the default registry forheaders,redirect, andcors.
#TypeScript
Two interfaces describe route rules. RouteRuleConfig types the configuration you write, while RouteRules types the values produced by matching and merging. Declare a custom rule with the same shape in both interfaces:
declare module "h3/rules" {
interface RouteRuleConfig {
/** Incremental Static Regeneration (handled at build time). */
isr?: number | boolean;
/** Add this route to the prerender queue. */
prerender?: boolean;
/** A data-only rule with no runtime handler. */
audience?: "public" | "internal";
}
interface RouteRules {
isr?: number | boolean;
prerender?: boolean;
audience?: "public" | "internal";
}
}
// event.context.routeRules.audience → "public" | "internal" | undefinedKey points:
RouteRuleConfigis closed. Unknown keys cause type errors, so TypeScript catches a typo such asredirct. Module augmentation adds your custom keys.RouteRulestypes merged values everywhere they appear:event.context.routeRules, therouteRulesreturned by a matcher, and each matched rule'soptionspassed to a handler.- Data-only rules pass through normalization and merging unchanged. Module augmentation affects only their types.
- H3 owns the
RouteRulesinterface and re-exports it fromh3/rules. Augmentingdeclare module "h3"therefore updates the same declaration used by Nitro and the standaloneh3-rulespackage.RouteRuleConfigexists only inh3/rules.
#Redeclaring Built-in Rules
Frameworks can also redeclare a built-in key when they provide a different rule shape. The augmented type replaces h3's type for that key:
declare module "h3" {
interface RouteRules {
redirect?: string | { to: string; status?: number };
cors?: boolean;
}
}The replacement can use any shape, including primitives and false. RouteRules is unconstrained. Built-in definitions live separately in BuiltinRouteRules and are added only for keys that have not been redeclared. This combined context type is exported as ResolvedRouteRules. A redeclaration replaces the built-in type instead of intersecting with it. Built-in keys you do not redeclare keep their exact option types, so expressions such as rules.redirect?.to do not require extra narrowing.
Note
h3 composes in only its own built-ins and declares no index signature. Contributing a blanket [key: string]: unknown to the shared interface would turn every other module's augmentation into a type error — which is why an undeclared data-only key is readable at runtime but not typed until you declare it.
Two more types are exported for integrations: NormalizedRouteRules (one pattern's rules after normalizeRouteRules() — RouteRules plus false resets and arbitrary names) and MatchedRouteRule (a merged rule with its provenance, what a rule handler receives).
#Security
The matcher protects against several path and ordering edge cases by default. Most apps do not need extra configuration. Read this section carefully if a custom rule restricts access.
#Encoded and Alternate Path Spellings
H3 matches rules against every meaningful interpretation of the request path, not only the spelling used for route dispatch. Otherwise, an attacker could bypass a rule by encoding the same path differently.
event.url.pathname decodes an escape only when the decoded character survives URL serialization. For example, /%40admin is already served as /@admin. Other values remain encoded, including separators (%2f, %5c), %25 at any nesting depth, values the serializer would encode again (%20 and non-ASCII characters), and C0 controls.
Route patterns, however, are normally written with the character itself. To prevent encoded paths from bypassing those patterns, h3 also resolves each request against:
- its canonical reading (encoded separators decoded,
./..resolved), - its slash-merged reading (what a downstream like nginx
merge_slashesresolves), - its percent-decoded reading.
routeRules({
"/@admin/**": { redirect: "/elsewhere" },
"/a admin/**": { redirect: "/elsewhere" },
});
// GET /@admin/data → matched
// GET /%40admin/data → matched (h3 serves it as /@admin/data)
// GET /a%20admin/data → matched (a proxied backend would serve it as "/a admin/data")
// GET /a%2520admin/x → matched (…and so would one that decodes twice)
// GET /admin%2fpanel → matched against /admin/panel tooAn alternate interpretation can add a rule or override it only with an equally or more specific pattern. A crafted path can never use a broader pattern to weaken the rule selected for the served path.
H3 also normalizes encoded rule patterns at configuration time. For example, /a%20admin/** becomes /a admin/**, so the pattern does not cover only the encoded spelling.
Rule keys decode escapes in the same way as h3 route patterns. An encoded Rou3 metacharacter therefore becomes a metacharacter: "/a/%3Aid" is the :id parameter pattern, and "/f/%2A%2A" is a catch-all. This matches the behavior of app.get("/a/%3Aid"). Only %2f, %5c, and %25 remain encoded and match literally, because decoding them would change the number of path segments.
Dispatch is unaffected: routing still uses event.url.pathname as served, and redirect / proxy still forward the raw path bytes.
#Resets and the restricting Flag
A false reset needs special handling across alternate path interpretations. Because a reset removes a rule, it would otherwise look the same as a rule that never matched, allowing a broader alternate interpretation to add it again. The matcher handles this based on whether the rule permits or restricts behavior:
- A rule that permits behavior stays reset. Restoring it could loosen the response, so the exemption wins. For example, a crafted path cannot undo
cors: falseon a private subtree. All built-in rules (cors,redirect,headers,cache, andproxy) belong to this category. - A rule that restricts behavior is added again. This fail-closed approach prevents an exemption for a single-segment pattern from carrying over to a decoded path with more segments.
Handlers declare which they are via RuleHandler.restricting, which defaults to false. No built-in sets it.
Important
A custom rule handler that restricts (an auth gate, a rate limit, an IP allowlist) must set restricting: true, or a false reset on one reading will exempt it on every other reading. The default is the safe choice for a permission and the wrong one for a restriction, and nothing warns. Rules with no handler at all (data-only rules a consumer such as Nitro acts on itself) cannot carry the flag — a consumer treating one as a gate needs its own reasoning at the point of use.
One residual, in the fail-safe direction: a permission reset is never resurrected, even by a reading that is a faithful re-spelling of the path that granted it. {"/**": { cors }, "/app/*": { cors: false }} keeps the exemption on /app/a%2fb, whose decoded reading has two segments and arguably falls outside the single-segment pattern that reset it. Every case this gets wrong errs toward not applying a permission.
#HEAD and Preflight Requests
HEAD requests are served by the matching GET route (RFC 9110), so GET-scoped rules apply to HEAD too — otherwise a rule keyed GET /admin/** could be dodged with a HEAD request. An explicit HEAD key still merges over (and can reset) the GET ones.
A CORS preflight arrives as OPTIONS, so a cors rule scoped to the method the browser announces in Access-Control-Request-Method is resolved for the preflight as well. Only the cors rule is taken from that lookup — never any other rule scoped to that method, since browsers send preflights without credentials and a gate lifted out of it would reject every preflight.
#Headers on Short-Circuited Responses
headers sits at order -1, so it wraps every rule that runs inside it — but not one that runs outside it. A response produced by an outer rule (the cors preflight answer, or a custom handler in the free -2 band) short-circuits before the headers middleware is entered, so the headers rule is not applied to it. Every other response goes through it, including error responses raised further in (a 404, or a handler that throws).
If a header must be present on such a response too, set it from global middleware registered before routeRules(), and set it on event.res.errHeaders as well — an error response is built from that bag, not from event.res.headers.
#Cached Routes and Global Middleware
The cache rule dispatches the matched route handler itself instead of calling the next layer, so global middleware registered after routeRules() never runs for a route a cache rule matched — on a cache miss just as much as on a hit:
app.use(routeRules({ "/api/**": { swr: 60 } }, { handlers: { cache } }));
app.use(requireAuth); // never runs for /api/** — not even on the first request
app.get("/api/private/:id", handler);Register routeRules() after every global middleware that has to run for cached routes:
app.use(requireAuth); // runs first, for every request
app.use(routeRules({ "/api/**": { swr: 60 } }, { handlers: { cache } }));Per-route middleware is unaffected — it is part of the composed route handler the cache rule dispatches, so it runs on a miss and is cached along with the response (which is its own reason not to put a credential check there). redirect and proxy end the chain too, but they answer the request outright and never reach the route handler, so this is only surprising for cache.
That same composition is why routeRules() belongs in app.use(), not on a route:
// Works, but the whole rule chain runs twice per request:
app.get("/api/x", handler, { middleware: [routeRules(rules, { handlers: { cache } })] });Registered this way (or composed in with defineHandler({ middleware })), the rule sits inside the very handler the cache rule dispatches, so the dispatch re-enters it. The rule detects the re-entry and continues to the route handler instead of dispatching a second time — the response and the caching are correct — but every other matched rule still runs on both passes. Keep routeRules() global.
#Redirect and Proxy Target Safety
For a /** target, the matched tail is appended and the resulting path is checked against the target's own base; a request that would escape it (for example via an encoded ..%2f traversal) is rejected with 400.
H3 builds the tail by removing the rule pattern's prefix, counted in segments. That prefix must therefore contain the same number of segments for every matching request.
Some patterns can match a variable number of prefix segments. These include a catch-all (/a/**/old/**), a modifier parameter (/:lang?/old/**, /x/:seg*/old/**), or a group that spans a separator (/x{/a}?/old/**). H3 rejects these requests with 400 rather than forwarding a path with the wrong prefix removed.
Plain parameters, *, regex parameters, and groups within one segment (/:lang/old/**, /x/*/old/**, /blog{-:title}?/old/**) each match exactly one segment and work as expected.
#CORS Credentials
credentials: true requires an explicit origin (allowlist or validation function). Combining it with a wildcard origin throws at startup, since Access-Control-Allow-Origin: * is invalid for credentialed requests.
#Build-Time Compiler
Framework and build-tool authors can compile a rule set into findRouteRules. This keeps Rou3 out of the runtime bundle:
import { compileRouteRules } from "h3/rules/compiler";
const mod = compileRouteRules(config, {
preMerge: true, // optional: bake pre-merged chains into the generated matcher
});
mod.code; // whole module (also `String(mod)` / template interpolation)
// -> import { headers as __ruleHandlers__$headers } from "h3/rules";
// -> export const findRouteRules = (method, path) => ...;compileRouteRules returns three forms:
imports: handler import statements.body: theexport const findRouteRules = …declaration.code: the complete module, also returned byString(mod).
Write code as a standalone module, or combine imports and body with a larger generated module.
Compiler entry points normalize their input automatically, so you can pass authored configuration directly. Already-normalized rules also work because normalization is idempotent.
At runtime, turn findRouteRules into a matcher with createMatcherFromFind(findRouteRules). Wrap that matcher with memoizeRouteRulesMatcher to enable memoization. Compiled and runtime matchers return the same results.
Important
The matcher API takes the method already uppercased — findRouteRules, createMatcherFromFind, and createRouteRulesMatcher all compare it as given. The routeRules() middleware normalizes for you; a hand-written wrapper must pass event.req.method.toUpperCase(), or a lowercase-spelled request will match no method-scoped rule at all and skip its gate.
Note
Rule options are embedded as JS object literals, so they must survive a JSON round-trip. A function, Date, or RegExp in a rule option (for example a cors.origin validation function) fails compilation with an explicit error instead of silently diverging from the runtime matcher.
#Specificity Guard
createMatcherFromFind applies a specificity guard by default. When one of a path's alternate readings (canonical, slash-merged, percent-decoded) resolves a rule differently from the served path, the alternate reading may only override with an equal-or-more-specific pattern — so a broad /** rule can never downgrade a narrower /admin/** one on a crafted %2f / %2e%2e path.
The default guard is dependency-free, so a compiled bundle stays free of Rou3. It decides containment from pattern shape, which is a conservative approximation of the exact relation: it allows only containment it can prove, so it never permits an override the exact relation would reject — but it is stricter. Where it cannot decide (a named catch-all such as **:rest, a regex or partial param, or a modifier param like :page? / :path*), it keeps the rule the served path resolved rather than applying the narrower one.
Use matcher: true to have the compiler bake the exact relation into the generated module — recommended whenever rule keys use modifier params — or pass your own predicate as the second argument, or () => true to disable the guard.
Ordering matched layers by specificity does not go through this predicate: it uses a rank computed when the rule set is built, so a compiled matcher without the baked relation still resolves the same rules as the runtime matcher.
#Matcher Export
To skip the hand-written wrapper, pass matcher so the generated module exports a ready-to-use matcher alongside findRouteRules:
compileRouteRules(config, { matcher: true });
// -> export const findRouteRules = …;
// -> import { createMatcherFromFind } from "h3/rules";
// -> export const matcher = createMatcherFromFind(findRouteRules, /* baked specificity guard */);
// rename the export, or bake in memoization:
compileRouteRules(config, { matcher: { name: "routeMatcher", memoize: true } });
// -> import { createMatcherFromFind, memoizeRouteRulesMatcher } from "h3/rules";
// -> export const routeMatcher = memoizeRouteRulesMatcher(createMatcherFromFind(findRouteRules, …));matcher: true names the export matcher; pass a string to rename it, or { name?, memoize? } to also wrap it in memoizeRouteRulesMatcher (memoize: { max } tunes the cap). memoizeRouteRulesMatcher is imported only when memoize is set, so an un-memoized matcher export still tree-shakes it away. The infra import counts toward mod.imports.
#Handler Sources
The generated module imports only the rule handlers the rule set uses. Most built-ins are a named export of h3/rules (headers, redirect, cors), except the opt-in subpath handlers: cache comes from h3/rules/cache and proxy from h3/rules/proxy, so their dependencies only enter the bundle when a matching rule exists.
Where each handler is imported from is controlled by runtimeRules — a record keyed by rule name whose value is either a module id (the module must export a member named exactly as the rule key) or { source, export } when the export is named something else. It is merged over the built-in preset (DEFAULT_RUNTIME_RULES), so you only list what you add or change. Handlers sharing a source collapse into one import statement:
import { compileRouteRules } from "h3/rules/compiler";
compileRouteRules(config, {
runtimeRules: {
cache: "#nitro/cache", // repoint the built-in cache at your own module
isr: { source: "#nitro/rules", export: "handleISR" }, // custom rule + export
},
});
// -> import { handleISR as __ruleHandlers__$isr } from "#nitro/rules";
// -> import { cache as __ruleHandlers__$cache } from "#nitro/cache";
// (redirect, headers, … still import from "h3/rules" when used)