# Routing: redirects, rewrites and headers

Bastide's edge can redirect a request, rewrite it to another path or another origin, and set response headers, before your application even receives it. This page presents the two places where these rules are declared, the simulator that checks them before publication, and how to go back to a previous version.

## Two places, two uses

| | Project routing rules | Deployment routes |
| --- | --- | --- |
| Where they live | In the database, per project | In the repository's `glowsoft.json` file |
| How they change | Through the API or the command line, without redeploying | With each deployment, along with the code |
| What they do | Redirects, internal rewrites and rewrites to an external origin | Redirects, response headers, internal rewrites |
| Syntax | Simple `*` and `:name` patterns | That of `vercel.json` |
| Volume | Up to 50,000 rules | 2,048 rules per block |
| History | 90 days of versions, one-step rollback | That of deployments: see [Restore](restore.md) |

Choose project rules for bulk redirects and for those that change without shipping code (a campaign, an address migration). Choose `glowsoft.json` for what follows the code: headers, rewrites for a single-page application, redirects that a release introduces.

### Evaluation order

For each request, the edge applies, in this order: the project's rate limit, preview protection, the project's routing rules, then the deployment routes, then the cache, and finally the application. Project rules therefore win over the repository's, and a project rewrite is read again by the deployment routes. A redirected request reads nothing from the cache and does not wake a sleeping application.

## Project routing rules

Project rules are published and read through the API and the command line. There is no console screen for them yet, and they are not open to agents: the MCP catalogue has no tool for them. A service account cannot use them either.

### A rule

| Field | Meaning |
| --- | --- |
| `source` | The path pattern, starting with `/`. `*` matches any run of characters, `/` included; `:name` matches one segment. No regular expressions. |
| `destination` | A path on the site, or an `https://` URL to a host on the allow list (rewrite only). `:name` and `*` reuse the captures, in order. |
| `type` | `redirect` or `rewrite`. |
| `status` | Redirect only: 301, 302, 307 or 308. Defaults to 308. |
| `conditions` | Up to 8 conditions on a request header: `equals`, `prefix`, `exists` or `absent`. |
| `timeoutMs` | External rewrite: response timeout, 5,000 ms by default, 10,000 ms at most. |
| `maxBytes` | External rewrite: response size, 5 MB by default, 10 MB at most. |

The order of the list is the precedence: the first rule whose pattern and conditions match the request answers. Write the specific rule before the general rule that covers it; the other way round, the specific rule would never be reached, and the simulator refuses it.

### External origins

A rewrite to `https://host/…` is only accepted if the host is in `allowedHosts`, the project's allow list. It only takes public domain names, in lowercase, without port or path: no IP address, no `localhost`, no internal suffix such as `.local` or `.internal`. The edge checks the host, the timeout and the size again at run time.

### Publishing

`routingrules.write` replaces the project's entire list: always send the complete list, in order. It becomes a numbered version, picked up by each node on its next pass, without redeploying. A list identical to the active version writes nothing.

```json
{
  "projectId": "prj_01JQ8ZK4M7NP2RSTVWXYZ34567",
  "rules": [
    { "source": "/old/:page", "destination": "/new/:page", "type": "redirect", "status": 301 },
    { "source": "/api/*", "destination": "https://api.example.com/v1/*", "type": "rewrite", "timeoutMs": 3000 }
  ],
  "allowedHosts": ["api.example.com"],
  "urls": [{ "path": "/old/pricing" }, { "path": "/api/products" }],
  "ifVersion": 3,
  "idempotencyKey": "routing-2026-10-26"
}
```

`ifVersion` carries the active version you read (0 if the project has none). If someone published in the meantime, the call is refused with 409 rather than overwriting their version.

### The simulator

Before writing anything, publication replays the list. A refused list writes nothing: the response is a 422 detailing each conflict. With `dryRun: true`, nothing is ever written and the report is returned, even if it refuses.

Conflicts (`conflicts`) carry a nature:

| Nature | Meaning |
| --- | --- |
| `loop` | A chain of rules comes back on itself, or exceeds 8 hops. |
| `shadowed` | A rule is never reached: a more general rule precedes it. |
| `collision` | Two wildcard patterns overlap and the order does not settle them. |
| `duplicate` | Two identical rules. |
| `invalid` | A refused form, or a rewrite to a host outside the list. |
| `incomplete` | The simulation hit its comparison budget without reaching a conclusion. |

For each URL in the set given in `urls` (1,000 at most, with optional headers), `results` gives the outcome: `redirect`, `rewrite`, `external`, `none` (no rule) or `loop`, with the destination, the status and the rules traversed. Build this set from the addresses that matter to you: it acts as a regression test on every publication.

### Versions and rollback

Each publication creates an immutable version: its number, date, author, rule count and fingerprint. The history keeps 90 days of versions.

`routingrules.read` returns the rules of the active version, or of a given version, page by page (200 by default, 1,000 at most), with the allow list and the history.

`routingrules.rollback` restores a version from the history. It copies it into a new version, after replaying the simulator: the history stays linear, and the rollback can itself be undone by restoring the version before it.

```sh
bastide routingrules read --project-id prj_01JQ8ZK4M7NP2RSTVWXYZ34567
bastide routingrules write --project-id prj_01JQ8ZK4M7NP2RSTVWXYZ34567 --rules "$(cat rules.json)" --allowed-hosts '["api.example.com"]' --dry-run
bastide routingrules rollback --project-id prj_01JQ8ZK4M7NP2RSTVWXYZ34567 --version 3
```

Through the API: `GET` and `PUT /v1/projects/{projectId}/routing-rules`, and `POST /v1/projects/{projectId}/routing-rules/rollback`, on `https://api.bastide.app`. Reading requires the Reader rank; publishing and rolling back, the Maintainer rank.

## Deployment routes

The `glowsoft.json` file, at the root of the repository, accepts three blocks whose shape and meaning are those of `vercel.json`: `redirects`, `headers` and `rewrites`. They are compiled at build time and frozen with the deployment: changing them requires a new deployment, and restoring a previous version also restores its routes. A rule the edge could not apply fails the build, with a message that locates the rule.

```json
{
  "redirects": [
    { "source": "/blog/:slug", "destination": "/articles/:slug", "permanent": true }
  ],
  "headers": [
    {
      "source": "/:path*",
      "headers": [{ "key": "Strict-Transport-Security", "value": "max-age=31536000" }]
    }
  ],
  "rewrites": [
    { "source": "/:path*", "destination": "/index.html" }
  ]
}
```

- **Redirects**: the first that matches the request answers. Without `permanent` or `statusCode`, the status is 308; `"permanent": false` gives 307. The `has` and `missing` conditions apply to a header, a cookie, a query parameter or the host. The `env` key is refused: the edge does not hold the project's variables.
- **Headers**: every rule that matches the request applies, in order. A header the application sets itself wins.
- **Rewrites**: the requested path is served first; a rewrite is only tried if the application answers 404 there, three at most per request. A destination on another origin is refused: use a project rule for that.

As in `vercel.json`, a pattern is case-sensitive, and `/:path*` does not cover the root `/`.

### Default security headers

The edge sets four security headers, frozen with each deployment:

| Header | Value |
| --- | --- |
| `X-Content-Type-Options` | `nosniff` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `X-Frame-Options` | `SAMEORIGIN` |
| `Permissions-Policy` | `browsing-topics=()` |

A `headers` block overrides them, and an empty value removes one. `Strict-Transport-Security` and a content security policy are not set by default: declare them if you want them.

## Limits

| Limit | Project rules | Deployment routes |
| --- | --- | --- |
| Number of rules | 50,000 | 2,048 per block |
| Weight | 4 MB of JSON per project; 16 MB request body | 256 KB of compiled rules |
| Wildcards per pattern | 4 | — |
| Length of a pattern or destination | 2,048 characters | 4,096 characters |
| Conditions per rule | 8 | 16 `has` and 16 `missing` |
| Allowed external hosts | 50 | none |
| History | 90 days | that of deployments |

These limits are the same on every tier.

## Common errors

| Symptom or code | Cause | Remedy |
| --- | --- | --- |
| 422 on publication | The simulator found a conflict. | Read `conflicts`, fix the order or the rule, replay with `dryRun`. |
| 409 `conflict` | `ifVersion` no longer matches the active version. | Read again with `routingrules.read` and redo your changes on the current version. |
| `invalid` on an external rewrite | The host is not in `allowedHosts`, or is not a public domain. | Add the exact host, in lowercase, without a port. |
| `shadowed` | A general rule precedes a specific one. | Move the specific rule first. |
| 508 served to the visitor | A chain of rules exceeds 8 hops at run time. | Simplify the chain; the simulator reports it as `loop`. |
| The build fails on `glowsoft.json` | A route uses a form the edge does not apply. | Read the message, which names the block and the rule's position. |
| A repository rewrite does not apply | The application answers something other than 404 on the requested path. | This is intended: existing content wins. |

## See also

- [Domains](domains.md)
- [Cache and images](cache.md)
- [Restore](restore.md)
- [Migrate from Vercel](from-vercel.md)
- [routingrules.write](../reference/operations/routingrules.write.md), [routingrules.read](../reference/operations/routingrules.read.md), [routingrules.rollback](../reference/operations/routingrules.rollback.md) (operation pages, in French)
