
# URL Redirects

Restructuring a site retires URLs. Without a redirect the only possible answer at a retired path is a 404, and every indexed link, bookmark and backlink pointing there breaks.

The `redirects` array declares those retired paths and where each one now lives:

```yaml
redirects:
  - from: /products/platform
    to: /
  - from: /products/partner
    to: /partner
  - from: /login
    to: /_admin/login
    status: 302
```

## Rule Properties

| Property | Description                                                                                                      |
| -------- | ---------------------------------------------------------------------------------------------------------------- |
| `from`   | Root-relative path to redirect away from. Must start with `/` and contain no whitespace, `?` or `#`.             |
| `to`     | Where to send the visitor — a root-relative path, or an absolute `http(s)://` URL to hand off to another origin. |
| `status` | `301` (default), `302`, `307` or `308`.                                                                          |

Query strings are not part of the match key. An incoming query string is preserved and carried onto the target, so there is no point encoding one in `from`. If `to` already carries its own query string, the incoming one is appended to it with `&`.

### Choosing a status

| Status | Meaning                                              | Use it when                                                    |
| ------ | ---------------------------------------------------- | -------------------------------------------------------------- |
| `301`  | Moved Permanently — transfers link equity            | The URL is retired for good. This is the default and the norm. |
| `302`  | Found — temporary, may rewrite the request method    | The move is temporary and the method does not matter.          |
| `307`  | Temporary Redirect — temporary, preserves the method | A temporary move that must keep a `POST` a `POST`.             |
| `308`  | Permanent Redirect — permanent, preserves the method | A permanent move that must keep the request method.            |

## Language Handling

A `from` is matched the way page paths are authored. A bare rule matches the plain path _and_ every language-prefixed variant, and a path `to` inherits the language of the request — so one rule serves every locale and a French visitor lands on the French replacement, never the English one:

```yaml
redirects:
  - from: /pricing
    to: /plans
```

That single rule answers `/pricing`, `/en/pricing` and `/fr/pricing`, sending each to `/plans`, `/en/plans` and `/fr/plans` respectively.

To target one locale only, write the language segment into `from`. A rule whose path already begins with a configured language code is matched literally:

```yaml
redirects:
  - from: /fr/tarifs
    to: /fr/plans
```

An absolute `to` is used verbatim, with no language handling, since it leaves the app entirely.

## Precedence

Redirects are evaluated **after static assets** and **before pages**:

1. A real file in the public directory — a redirect rule can never hijack a served asset.
2. Redirect rules.
3. Page resolution.
4. The 404 catch-all.

A rule therefore always beats a page. That cuts both ways: a `from` matching a path you still serve makes that page unreachable.

:::callout
**Redirects resolve in exactly one hop.** A matched request emits one redirect and the target is never re-matched against the table, so `Location` is always literally what you authored. Chains do not collapse — if `/a` should end at `/c`, write `/a → /c`, not `/a → /b → /c`.
:::

## Validation

Three whole-table rules are enforced when the config is decoded, so a broken table fails `sovrium validate` instead of shipping:

- **No duplicate `from`** — each path may declare at most one rule.
- **No self-redirect** — a rule pointing at its own `from` loops forever.
- **No cycles** — `/a → /b` together with `/b → /a` is rejected. The server only ever resolves one hop, but the _browser_ follows each new rule in turn and never settles.

Protocol-relative targets (`//example.com`) are also rejected. A browser resolves them as absolute cross-origin URLs, which would turn the redirect table into an open-redirect primitive; cross-origin hand-offs must spell out `https://` so the intent is visible in review.

:::callout
**Collision with a dynamic route is not caught for you.** Validation compares `from` against static page paths only. If your app serves a dynamic route such as `/blog/:slug`, a rule for `/blog/my-post` will silently shadow that article — no boot error, no validation failure. Check retired slugs against the content that still exists.
:::

## Related Pages

- [App Metadata](/en/docs/app-metadata) — the other top-level identity properties.
- [Pages Overview](/en/docs/pages-overview) — the page resolution that redirects run ahead of.
- [Languages](/en/docs/languages) — the configured language codes that drive prefix matching.
- [SEO & Metadata](/en/docs/seo-meta) — canonical URLs, which a 301 should agree with.
