
# 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:

```ts
routeRules({
  "/old/**": { redirect: "/new/**" },
  "/assets/**": { headers: { "cache-control": "s-maxage=31536000" } },
  "/api/**": { cors: true },
});
```

Add `routeRules()` as global middleware. For each request, it:

1. Finds every matching pattern.
2. Merges the matching rules.
3. Runs their middleware in the documented [execution order](#execution-order). Depending on the rule, it may wrap the response or answer the request directly.
4. Makes the merged rules available as `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](#data-only-rules) and the [compiler](#build-time-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

```ts [server.mjs]
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);
```

:read-more{to="/guide/basics/middleware" title="Middleware"}

## 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`](/utils/security#handlecorsevent-options). Preflight (`OPTIONS`) requests are answered directly. |
| `cache`    | Cache the matched route handler's response. Opt-in, see [Caching](#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](#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`](/utils/security#basicauthopts) or your own middleware. If you create an auth rule with a [custom handler](#custom-rule-handlers), read [Security](#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.

```ts
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:

```ts
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](#proxying) 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:

```ts
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](#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](#proxying).

## How Matching Works

Route rules use [🌳 Rou3](https://github.com/h3js/rou3), the same engine as [routing](/guide/basics/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.

```ts
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:

```ts
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.

```ts
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:

```ts
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](#memoization) 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](#using-matchers-directly) directly:

```ts
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 patterns
```

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

```ts
routeRules({
  "/docs/**": { prerender: true },
});

// event.context.routeRules.prerender → true
```

Declare custom data-only keys to make them type-safe. See [TypeScript](#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](https://github.com/unjs/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.

```ts
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:

```ts
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 `cache` export.
- The **method** prevents a body-less `HEAD` response from being stored as the `GET` response.

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, or `allowQuery: true` to include the full query string.
- **Headers are removed unless listed in `varies`.** See [Credentials and Cookies](#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 `Vary` header that the key does not cover, sets `Cache-Control: no-store`, `private`, or `no-cache`, has a status other than `200`, `203`, `301`, or `308`, or is larger than `maxBodySize`.

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 to `Vary`, 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 `500` instead of caching a credentialed response under a credential-free key.
- Headers listed in `varies` remain visible to the cached handler. Each value gets its own key and is added to the response's `Vary` header. Credentials are an exception: listing them in `varies` does not forward them. Use `allowAuthorization` instead.
- A handler's `Set-Cookie` header 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. `allowCookies` only 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-Control` header when it contains `private` or `no-store`. Ocache also refuses to store the response.
- H3 replaces any other handler-provided `Cache-Control` value with the rule's generated `public, max-age=N, s-maxage=N`. Use a `headers` rule when you need full control over the final value.
- Set `sendCacheControl: false` to 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:

```ts
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`](/utils/proxy#proxyrequestevent-target-opts), 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:

```ts
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`](#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 handler
```

In practice:

- CORS can answer a preflight request before any other rule runs.
- `headers` wraps every rule inside it, so its values override headers produced by caching or other inner rules.
- `redirect`, `proxy`, and `cache` can finish the request without calling the next rule. Each has a separate order. `cache` is innermost and dispatches the route handler.
- Because `cache` dispatches the route itself, it also ends the app's _global_ middleware chain. See [Cached Routes and Global Middleware](#cached-routes-and-global-middleware).
- Custom rules use order `0` by default, so they run before `redirect`, `proxy`, and `cache`.

`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](#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](#memoization). |
| `preMerge` | Resolve each pattern's subsumption chain at startup, see [Pre-merging](#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? }`:

- `handler` turns a matched rule into H3 [middleware](/guide/basics/middleware).
- `order` controls when it runs. Lower values run first and the default is `0`. Built-in rules use `-3` through `-1` and `1` through `3`; see [Execution Order](#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`.

```ts
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](#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 `1024` entries by default and uses FIFO eviction. Dynamic paths therefore cannot grow it without limit. Change the cap with `memoize: { max }`.
- Use `memoize: false` to 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](#build-time-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:

```ts
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 as `swr`, normalizes string and boolean forms, and canonicalizes keys. `createRouteRulesMatcher()` expects these **normalized** rules, unlike `routeRules()`. 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. `ruleHandlers` is the default registry for `headers`, `redirect`, and `cors`.

## 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:

```ts
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" | undefined
```

Key points:

- `RouteRuleConfig` is **closed**. Unknown keys cause type errors, so TypeScript catches a typo such as `redirct`. Module augmentation adds your custom keys.
- `RouteRules` types merged values everywhere they appear: `event.context.routeRules`, the `routeRules` returned by a matcher, and each matched rule's `options` passed to a handler.
- Data-only rules pass through normalization and merging unchanged. Module augmentation affects only their types.
- H3 owns the `RouteRules` interface and re-exports it from `h3/rules`. Augmenting `declare module "h3"` therefore updates the same declaration used by Nitro and the standalone `h3-rules` package. `RouteRuleConfig` exists only in `h3/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:

```ts
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`](/guide/api/h3event#pathname-encoding) 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_slashes` resolves),
- its **percent-decoded** reading.

```ts
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 too
```

An 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: false` on a private subtree. All built-in rules (`cors`, `redirect`, `headers`, `cache`, and `proxy`) 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](https://www.rfc-editor.org/rfc/rfc9110#name-head)), 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:

```ts
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:

```ts
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:

```ts
// 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:

```ts
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`: the `export const findRouteRules = …` declaration.
- `code`: the complete module, also returned by `String(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](#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](#encoded-and-alternate-path-spellings) (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`](#matcher-export) 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`:

```ts
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:

```ts
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)
```
