> ## Documentation Index
> Fetch the complete documentation index at: https://cactal.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Configure routes and redirects

> Define static and parameterized page routes, understand matching and canonical URLs, and configure validated local or external redirects in website_config.

Cactal routes requests to page files, then renders the matching page with path and query parameters. Use `redirects` in `website_config` when an old URL should move before page matching begins.

## Define a page route

Every page must directly default-export `definePage({ ... })` and contain one inline `route` string literal:

```tsx pages/post theme={null}
import { definePage } from '@website'

export default definePage({
	route: '/blog/:slug',
	render: ({ param }) => (
		<main>
			<h1>Post: {param.path('slug')}</h1>
		</main>
	)
})
```

Route parameters occupy a complete segment and begin with `:`. A parameter name must start with a letter and can contain letters, numbers, `_`, or `-`.

| Route                  | Matches                       | Does not match                    |
| ---------------------- | ----------------------------- | --------------------------------- |
| `/`                    | `/`                           | `/home`                           |
| `/about`               | `/about`, `/ABOUT`, `/about/` | `/about/team`                     |
| `/blog/:slug`          | `/blog/hello-world`           | `/blog`, `/blog/2026/hello-world` |
| `/docs/:section/:page` | `/docs/api/authentication`    | `/docs/api`                       |

Static segments can contain lowercase letters, numbers, `.`, `_`, `~`, and `-`. Cactal does not support wildcards, catch-all parameters, optional segments, regular expressions, query strings, or hash fragments in route patterns.

### Canonical route normalization

Cactal stores canonical routes in lowercase, without trailing slashes. The quality gate can rewrite a noncanonical literal such as `/Blog/:Slug/` to `/blog/:slug` before publishing.

Requests are matched case-insensitively and without a trailing slash. A request whose path is not canonical receives a `301` redirect to the canonical path, with its query string preserved. Dynamic parameter values keep their original case.

<Tip>
  Author lowercase routes without trailing slashes so the quality gate does not need to rewrite source.
</Tip>

### Matching priority and ambiguity

Routes with more static segments win. A fully static route beats a parameterized route:

```tsx theme={null}
// Matches /blog/archive before /blog/:slug.
route: '/blog/archive'

// Matches other one-segment blog paths.
route: '/blog/:slug'
```

Two routes cannot have the same normalized pattern. They also cannot differ only by parameter names, because `/blog/:slug` and `/blog/:id` match the same URLs. `head/check` reports every duplicate or ambiguous route it finds.

### Reserved paths

Page routes and redirect sources cannot claim Cactal's serving paths:

* `/robots.txt`
* `/sitemap.xml` and `/sitemap-<n>.xml`
* `/_framework` and anything below `/_framework/`

`/favicon.ico` is not reserved. A page or authored redirect for `/favicon.ico` takes precedence over the website favicon setting.

## Link between pages

Use ordinary anchors for links within your website:

```tsx theme={null}
<a href="/about">About us</a>
```

Cactal serves a complete server-rendered document for the first visit, refreshes, and browsers without JavaScript. After the site loads, eligible same-origin links navigate in place while Cactal retrieves the destination page's CMS data and metadata. No router imports or special link components are required.

When a destination takes long enough to load, a thin contrast-adaptive progress line appears at the top of the viewport while the current page remains available.

To use a specific color instead, set `--cactal-navigation-progress-color` in your global CSS:

```css theme={null}
:root {
  --cactal-navigation-progress-color: #2563eb;
}
```

The property accepts any CSS color. Leave it unset to keep the automatic contrast-adaptive color.

External links, downloads, links that open another browsing context, modified clicks, and links to a fragment on the current page keep their native browser behavior.

## Read request parameters

The `param` reader is available in `render`:

```tsx pages/search theme={null}
import { definePage } from '@website'

export default definePage({
	route: '/catalog/:category',
	render: ({ param, path, params, query }) => {
		const category = param.path('category')
		const search = param.query('q')

		return (
			<main>
				<p>Path: {path}</p>
				<p>Category: {category}</p>
				<p>Search: {Array.isArray(search) ? search.join(', ') : search}</p>
				<pre>{JSON.stringify({ params, query }, null, 2)}</pre>
			</main>
		)
	}
})
```

`param.path(name)` is type-checked against the page route and returns one string. `param.query(name)` returns a string, an array when the key is repeated, or `undefined`. The raw `params` and `query` maps contain the same values.

Use the query-time parameter helper to bind request values into CMS queries. See [Build pages with CMS data](/docs/guides/build-pages-with-cms-data).

## Configure redirects

Add an inline `redirects` array to the `website_config` singleton. `metadata` and `redirects` can coexist:

```tsx website_config theme={null}
import { defineWebsiteConfig } from '@website'

export default defineWebsiteConfig({
	metadata: {
		title: 'Acme Journal',
		titleTemplate: '%s | Acme Journal'
	},
	redirects: [
		{ from: '/news', to: '/blog', status: 301 },
		{ from: '/blog/:slug', to: '/articles/:slug', status: 308 },
		{
			from: '/help/:article',
			to: 'https://support.example.com/articles/:article',
			status: 302
		}
	]
})
```

Each rule requires exactly three fields:

| Field    | Type   | Behavior                                                                                       |
| -------- | ------ | ---------------------------------------------------------------------------------------------- |
| `from`   | string | Site-relative source pattern, using the same static and `:param` segment syntax as page routes |
| `to`     | string | Site-relative path or absolute `http`/`https` URL                                              |
| `status` | number | `301`, `302`, `307`, or `308`                                                                  |

Redirects run before page matching and server rendering. The first matching rule wins, so put specific rules before broader parameterized rules:

```tsx theme={null}
redirects: [
	{ from: '/blog/special', to: '/featured', status: 302 },
	{ from: '/blog/:slug', to: '/articles/:slug', status: 301 }
]
```

If those rules were reversed, `/blog/special` would redirect to `/articles/special`.

### Redirect status codes

| Status | Persistence | Request method behavior                    | Common use                                       |
| ------ | ----------- | ------------------------------------------ | ------------------------------------------------ |
| `301`  | Permanent   | A client may change non-GET methods to GET | Permanent URL migration for ordinary pages       |
| `302`  | Temporary   | A client may change non-GET methods to GET | Temporary page move                              |
| `307`  | Temporary   | Preserves the request method and body      | Temporary redirect for method-sensitive requests |
| `308`  | Permanent   | Preserves the request method and body      | Permanent method-preserving migration            |

Browsers and search engines cache permanent redirects aggressively. Use `302` or `307` until you are certain a move is permanent.

### Parameters and query strings

A target can reuse any parameter declared by its source:

```tsx theme={null}
{ from: '/store/:category/:slug', to: '/shop/:category/:slug', status: 308 }
```

A target parameter that does not exist in `from` fails validation. Parameter names cannot repeat within one pattern.

Incoming query strings are always preserved and appended to the target. Do not include a query string or hash in `from` or `to`:

```text theme={null}
/news?ref=email  ->  /blog?ref=email
```

### Local and external targets

Local targets begin with `/`. External targets must be absolute `http` or `https` URLs without credentials, query strings, or hashes:

```tsx theme={null}
redirects: [
	{ from: '/community', to: 'https://community.example.com/', status: 302 }
]
```

Protocol-relative URLs, unsafe schemes, and URLs containing a username or password are rejected.

### Validation and loop protection

`head/check` and publish reject:

* Empty paths, whitespace, repeated slashes, trailing slashes, query strings, and hashes
* Unsupported status codes
* Duplicate source patterns, including duplicates that differ only by case
* Self-redirects and local redirect loops, including loops through parameterized routes
* Target parameters absent from the source
* Redirects from reserved serving paths
* Unknown, computed, duplicate, or spread properties

The `redirects` value must be an inline array of inline object literals. Variables, function calls, object spreads, array spreads, and computed keys are not statically extractable:

```tsx theme={null}
// Invalid
const redirects = [{ from: '/old', to: '/new', status: 301 }]

export default defineWebsiteConfig({ redirects })
```

## Edit redirects through the API

There is no partial redirects endpoint. Read the current head snapshot, update the complete `website_config` source, then upsert the singleton with `PUT /v1/websiteSourceCode/files`:

```ts theme={null}
const source = `import { defineWebsiteConfig } from '@website'

export default defineWebsiteConfig({
	metadata: { title: 'Acme' },
	redirects: [
		{ from: '/old', to: '/new', status: 308 }
	]
})`

const response = await fetch(
	'https://api.cactal.ai/v1/websiteSourceCode/files',
	{
		method: 'PUT',
		headers: {
			Authorization: `Bearer ${process.env.CACTAL_API_KEY}`,
			'Content-Type': 'application/json'
		},
		body: JSON.stringify({
			websiteId: 'V1StGXR8_Z5jdHi6B-myT',
			file: { type: 'website_config' },
			content: source,
			leaseToken
		})
	}
)

if (!response.ok) throw new Error(`Update failed: ${response.status}`)
```

<Warning>
  The file upsert replaces the complete `website_config` source and is not framework-validated until `head/check` or publish. Preserve existing metadata and redirect rules when editing it.
</Warning>

`PATCH /v1/websiteSourceCode/metadata` without a `page` edits only the `metadata` property and preserves redirects. Use it for semantic metadata changes, not redirect changes.

## Publish and verify

Redirects are source-versioned. They appear on the draft host after its preview build is ready and reach production only after you publish the source version.

```bash theme={null}
curl -I 'https://draft--acme.cactal.app/old?ref=test'
```

Confirm the status and complete `Location` header, including preserved query parameters. Then run `head/check`, publish, and repeat the request against the production hostname.

## Troubleshooting

| Problem                                   | Cause                                            | Fix                                                                        |
| ----------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------- |
| A page never renders                      | A redirect shadows its route                     | Reorder or remove the matching redirect                                    |
| A specific redirect uses the broad target | A parameterized rule appears first               | Put the specific rule before the broad rule                                |
| The query string is duplicated            | The authored target contains query data          | Remove the target query; Cactal preserves the incoming query automatically |
| `website_config redirects is invalid`     | The rule is not a supported inline literal       | Use only inline `{ from, to, status }` objects                             |
| A loop is reported                        | Local targets eventually match an earlier source | Point one rule at a final page or external URL                             |
| Production still uses old redirects       | The source change is only in head                | Wait for the draft build, run `head/check`, and publish                    |

## Next steps

* [Build pages with CMS data](/docs/guides/build-pages-with-cms-data) for typed queries and render props
* [Configure SEO metadata](/docs/guides/configure-seo-metadata) for canonical URLs and crawler metadata
* [Edit source code](/docs/guides/edit-source-code) for leases, snapshots, and file upserts
* [Preview drafts and versions](/docs/guides/preview-drafts-and-versions) before publishing a redirect migration
