Redirects
Redirects are content, so they are edited in the console and read from the API. Your build turns them into whatever your host expects.
Where they are authored
Two places, and one endpoint returns both.
On the page that owns them. A document declares the old paths that lead to it in page settings, under Old paths that lead here. It is stored on the document as meta.redirectsFrom, a list of plain paths:
{
"route": "/products/doodloop",
"meta": { "redirectsFrom": ["/products/cels", "/cels"] },
"zones": { "main": { "blocks": [] } }
}This is the usual case: the page moved, so the page says where it used to be. The target needs no reference of its own, because the target is the document holding the list. Move the page and its old paths follow it.
As a document of its own. Content of type redirect, whose route is the old path and whose redirect.to is a typed link. Use it for a redirect that belongs to no page: a campaign URL, or a path inherited from a site that no longer exists. The typed link means the rule follows the document it points at when that document moves.
The source side of a redirect is always a plain path, never a typed link. A source is by definition a path with no document behind it, which is the whole reason it needs a redirect.
Reading them
GET {API}/api/v1/{siteId}/{env}/_redirects{
"redirects": [
{ "from": "/products/cels", "to": "/products/doodloop", "status": 301 }
]
}Only published redirects appear. One with no target is left out rather than returned as a rule pointing nowhere, and so is one that conflicts: two documents claiming the same old path, a path that is also a live page, or a chain of redirects that loops. Publishing those is refused by the release gate in the first place, so an environment that ships through releases never has any; delivery drops them because a rule that hides a live page is worse than a redirect that is missing.
Generating a redirects file
Cloudflare Pages, as an example:
// scripts/cms-redirects.mjs
import { writeFileSync } from 'node:fs'
const res = await fetch(`${API}/api/v1/${SITE}/${ENV}/_redirects`)
if (!res.ok) {
console.error(`CMS answered ${res.status}; refusing to regenerate redirects.`)
process.exit(1)
}
const { redirects } = await res.json()
const lines = redirects.flatMap((r) => [
`${r.from} ${r.to} ${r.status}`,
// Pages matches the exact path, so a trailing-slash visit would miss.
...(r.from.endsWith('/') ? [] : [`${r.from}/ ${r.to} ${r.status}`]),
])
writeFileSync('public/_redirects', [...lines, '/* /index.html 200', ''].join('\n'))Two things worth copying:
Exit non-zero if the CMS does not answer. Writing the file from a failed response empties it, and every redirected URL starts returning 404.
Keep hosting rules out of the CMS. An SPA fallback is a fact about how the site is deployed, not content. Add it in the generator.
Handling them at runtime instead
If your site renders at request time, read the list once and match:
const map = new Map(redirects.map((r) => [r.from, r]))
const hit = map.get(url.pathname)
if (hit) return Response.redirect(hit.to, hit.status)