Meridian handbook
Features

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 fromHow you add them
The engine, when an address changesAutomatically; see below
redirects: in routes.yamlBy hand, under version control
A CSV importengine-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

WhenWhat the engine doesOrigin
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 changesA 301 from each descendant’s old path.auto_route
A live document is trashedA 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 destinationA 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 ruleA 301 to the lower-case path, keeping the query string.auto_case
A path finds nothing but has live pages beneath itA 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
KeyMeaning
fromThe path. Containing *: a wildcard, each * matching any run of characters. Ending in /: a prefix, matching the path and everything beneath it. Otherwise exact.
toThe 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.
status301 (default), 302, 307, 308 or 410. A 410 has no to.
localeApply 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 no to when 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:

  1. config (from routes.yaml)
  2. managed
  3. imported
  4. auto_slug
  5. auto_route
  6. auto_trash and auto_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.

CommandFlags
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.