Redirects and URLs
How addresses stay stable: slugs, the automatic redirects on a slug change, trash or move, redirect and 410 rules in routes.yaml, importing a CSV of redirects from an old site, the order rules are matched in, and how missing pages are recorded.
A page’s address comes from its route’s pattern and its fields, usually a slug. When an address changes, the engine keeps the old one working with a redirect it creates itself. For a site migration you import the old site’s addresses from a CSV, and for structural rules you write redirects in routes.yaml. Every path that finds nothing is counted, per site and locale.
| Redirects come from | How you add them |
|---|---|
| The engine, when an address changes | Automatically; see below |
redirects: in routes.yaml | By hand, under version control |
| A CSV import | engine-dmo redirects import |
There is no admin screen or command to add a single redirect by hand (the managed origin below is reserved for one), and no screen or command reads the missing-page counts yet. Put a one-off redirect in routes.yaml, or import a one-row CSV.
Slugs
A slug field is a URL segment per locale, generated from another field and transliterated to ASCII:
fields:
- name: title
type: text
required: true
- name: slug
type: slug
source: title # the field the slug is generated from
max: 80 A route’s uri pattern uses it: uri: "/whats-on/{slug}". Routes, nesting and locale prefixes are covered in Sites, locales and routes; the field in Field types.
Automatic redirects
| When | What the engine does | Origin |
|---|---|---|
| A published locale’s address changes (a new slug, a new parent) | A permanent redirect (301) from the old path to the document, per locale. It follows the document, so a later rename does not break it. A rule from the new path left by an earlier rename is disabled. | auto_slug |
A page moves in a hierarchy, or a route’s parent or pattern changes | A 301 from each descendant’s old path. | auto_route |
| A live document is trashed | A redirect from each released path to the choice made when trashing: the type’s index page (parent), a path, or none with a reason. It is a 302 while the document can still be restored, upgraded to 301 when it is purged. | auto_trash |
| A live locale is unpublished with a destination | A 302 to the destination; publishing the locale again disables it. | auto_unpublish |
| A path with capital letters finds nothing, and its lower-case form is a page or a rule | A 301 to the lower-case path, keeping the query string. | auto_case |
| A path finds nothing but has live pages beneath it | A 301 to the nearest ancestor path that is a page, keeping the query string. | auto_segment |
The last two are decided at request time and never stored as rules.
Declining a slug redirect needs a reason, which is audited:
engine-dmo content publish --id <document-id> --locale en --no-redirect "the old address was never public"
engine-dmo content trash --id <document-id> --redirect parent
engine-dmo content trash --id <document-id> --redirect /whats-on
engine-dmo content trash --id <document-id> --redirect none content trash requires --redirect when any locale of the document is live. The admin’s trash action asks for the same choice.
Unpublishing with a destination (auto_unpublish) is offered only by the content MCP’s unpublish tool (content MCP). The admin’s unpublish asks for no destination and there is no content unpublish command, so an unpublished page otherwise answers 404 at its old address. To send its visitors somewhere, add a rule for the old path in routes.yaml.
A rule that points at a document resolves the document’s current address when it is matched, and is skipped while the document has none. When the document is purged, the rule becomes a 410 for its path.
Rules in routes.yaml
Structural rules that belong to the project go in the site’s routes.yaml, under version control:
# config/sites/<site>/routes.yaml
redirects:
- { from: /visit-us, to: /visit } # exact, 301
- { from: /whats-on/, to: "/events/{1}" } # prefix: the rest of the path is {1}
- { from: "/news/*/amp", to: "/news/{1}", status: 308 } # wildcard: each * is {1}, {2}, ...
- { from: /old-shop, to: "https://shop.example.org/", status: 302 }
- { from: "/cgi-bin/*", status: 410 } # gone: no target
- { from: /welcome-old, to: /welcome, locale: en } # only for this locale | Key | Meaning |
|---|---|
from | The path. Containing *: a wildcard, each * matching any run of characters. Ending in /: a prefix, matching the path and everything beneath it. Otherwise exact. |
to | The target: a path on the site, an absolute URL, or a template with {1}, {2} for the captures. Quote a value containing braces. Required unless status is 410. |
status | 301 (default), 302, 307, 308 or 410. A 410 has no to. |
locale | Apply only to paths in this locale. Without it, every locale. |
engine-dmo check reports, at the rule’s line:
- a rule with no
from, or notowhen it is not a 410; - a 410 with a
to; - a status outside the five;
- a loop (an error):
redirect loop: /a -> /b -> /a; - a chain of more than one hop (a warning):
redirect chain: /c -> /visit-us -> /visit, with the hint to point the first rule at the last target; - an internal target no route, index, special page, home page, endpoint or other rule of the site answers, in a locale the rule applies to:
redirect target /nowhere resolves to nothing on site main in en. A target built from captures and an external address are not checked.
Matching
Redirects are consulted only when a path finds nothing else: a published document, a type’s index page, a module’s page, a public file. A real page at a path always wins over a rule for it. This is the order for a GET:
flowchart TD
req["GET /path"] --> ep{"endpoint, feed or<br/>module path?"}
ep -->|yes| engine["The engine answers"]
ep -->|no| doc{"document, index or<br/>landing page?"}
doc -->|yes| page["Render the page"]
doc -->|no| file{"file in public/?"}
file -->|yes| static["Serve the file"]
file -->|no| rule{"redirect or 410 rule,<br/>or a case or segment fix?"}
rule -->|yes| redirect["301, 302, 307, 308 or 410"]
rule -->|no| miss["Count the miss"]
miss --> notfound["404 with errors/404.html"]Paths are matched with their trailing slash removed. Exact rules are matched first, a locale-specific rule before an all-locale one. Then prefix and wildcard rules, the longest literal prefix first. (Regular-expression rules cannot be written in routes.yaml or imported.) Between rules for the same path, the origin decides:
config(fromroutes.yaml)managedimportedauto_slugauto_routeauto_trashandauto_unpublish
The visitor’s query string is carried over to the target. A 410 answers with status 410. A matched rule’s hit count and last hit time are recorded.
Importing redirects from an old site
On migration day, import the old site’s addresses as exact rules from a CSV:
source,target,status,locale,note
/attractions/broughton-castle,/places/broughton-castle,301,,old CMS
/whats-on/2025,/whats-on,301,,
/accommodation,document:0b8f3c2e-5a61-4c39-9d1e-2f7a1b9c4d10,301,en,follows the page
/wp-content/uploads/map.pdf,,410,,never coming back engine-dmo redirects import --site main --file redirects.csv --dry-run
engine-dmo redirects import --site main --file redirects.csv
engine-dmo redirects list --site main --origin imported
engine-dmo redirects rollback --site main --batch <batch-id> The header row names source and target, and optionally status, locale and note; other columns are ignored. A target is a path on the site, an absolute URL, document:<uuid> (the rule follows the document if its address changes), or empty with status 410.
Every row is checked: the source and status, the locale, a duplicate in the file or in the table, the target (a document that exists, a path the site answers) and loops and chains through every rule the site has. Any error refuses the whole import unless --skip-invalid, which imports the valid rows. The import is one transaction and prints a batch id; redirects rollback --batch disables every rule that import wrote, audited.
| Command | Flags |
|---|---|
redirects import | --file, --site, --dry-run (check every row, write nothing), --skip-invalid |
redirects list | --site, --origin (config, managed, imported, auto_slug, auto_route, auto_trash, auto_unpublish): the table’s rules, newest first |
redirects rollback | --batch, --site |
All need the database.
Missing pages
Every request that ends in a 404 is counted per site, path and locale: first seen, last seen, hits, and hits from known bots counted apart. Paths that look like probes (/.env, /wp-admin, .php and the like) are marked as scanners; paths longer than 512 bytes are not recorded. No visitor identity is stored. A headless site’s misses are recorded through the content API’s resolve operation in the same way. engine-dmo render does not count its misses.
The counts are kept for a report, but no admin screen or command reads them in this version. Until one does, use your web server’s or CDN’s logs to find the old addresses that need a rule.
Headless sites
The content API serves a site’s complete, ordered rule list for a frontend or an edge function to apply, and resolves a path to a page, a redirect, a 410 or a miss. See the content API.
Newsletters
How the built-in newsletter works: capturing subscribers with double opt-in through a declared endpoint, topics and consent records, the confirmation and preference centre pages, composing and sending campaigns from live content, tracking, deliverability checks and the newsletter commands.
Analytics
What the built-in, cookieless analytics collects and how (server-side views, the beacon, tracked links, impressions), why it needs no consent banner, the template helpers, dashboards in analytics/dashboards.yaml, exports, retention and every analytics setting.