ADR 0005 — Legacy URLs: keeping a website's search results across a relaunch
- Status: Accepted
- Date: 2026-08-27
- Context repos: cratly.io site (this repo),
scavold,editor,s3-http
Problem
Most cratly websites replace an existing one. The old site has addresses that search engines know and that other sites link to, and every one of them the new site fails to answer is a result its owner loses — silently, because nothing in a build or a deploy notices that an address which used to exist no longer does.
Two constraints shape the answer:
- Only the server can answer an address that is no longer a file, and only a server-side permanent redirect (
301) passes the accumulated ranking on. A redirect run by the browser —<meta http-equiv="refresh">,location.replace()— is followed, but counts for less and takes longer to be honoured. - cratly does not know who serves the site.
s3-httpis one option, a Caddy or nginx in front of a bucket is another, and a customer's own hosting is a third. Nothing in the product definition may assume one of them.
The second constraint is what makes this an ADR rather than a feature: the naive shape — the adapter writes redirect rules straight into .s3-http.config.yaml — would make one hosting service part of cratly.
Decision
Three tiers, in this order of preference.
- Keep the address. A page served at the legacy path needs no redirect at all, spends no ranking, and works on every static server whether or not it can redirect. This is what the existing
urlfront matter key does, and it covers a relaunch that keeps its structure, including legacy.htmlpaths. - Declare the address and let the server redirect it. For addresses that cannot be files —
.php, query strings, merged pages, changed language prefixes — pages declare what they replace and the build writes it out as data (below). - Client-side redirect, the existing string-form
redirectfront matter, stays a documented fallback for hosts that cannot redirect at all — explicitly weaker for search engines, and never the recommendation.
Declaration is author-facing and framework-agnostic.
aliasesin a page's front matter: the former addresses that page replaces, written as they stood in the browser.retired_urlsin.cratly.config.yaml: addresses that are not coming back, which get status410so a search engine drops the entry instead of asking again. They belong at site level because a retired address has no page to carry it.
The build emits a neutral artifact, not server configuration.
The adapter writes cratly-redirects.json into the build output — an address, where it went, a status, nothing else. Grammar: /schema/redirects/v0.json, format documented under Legacy URLs. This mirrors ADR 0001: cratly owns the format, the adapter produces the artifact, consumers read it.
Translation into server configuration belongs to the deploy step.
Whoever deploys reads the artifact and writes what its target understands — an .s3-http.config.yaml, a Caddy or nginx rule set, a bucket's routing rules, a _redirects file. s3-http gains redirect support as one consumer of the format, not as its definition.
The editor writes page front matter only. It offers the former addresses of a page as a field and validates them; it never writes hosting configuration in any form.
Why the artifact lives in the build output
The redirect map describes the build it belongs to: it names the pages that build produced, and it changes with them. Putting it in the output means it travels with the artifact that is uploaded, needs no second channel, and cannot go stale relative to the site it describes. This is the opposite trade-off from .cratly/sections.json, which is committed precisely because its consumer — the editor — must read it without a build.
A deploy step may strip the file before publishing; it carries no secrets, so leaving it in place is equally fine.
Validation, and why it stops a build
Two declarations cannot be served as meant, and both are refused rather than resolved:
- Two pages claiming one former address. Which page a visitor lands on would be decided by iteration order.
- A former address the new site serves itself. The redirect wins over the page, so the page becomes unreachable while every menu on the site still links to it. This is the same family as the page-identity collision from ADR 0001's follow-up work: a URL claimed twice with only one winner and no complaint.
Consequences
- Preserving a relaunch's search results becomes a declaration in the repository rather than hosting handiwork, and it is visible to review like any other content change.
- The URL inventory of the old site has to be collected while that site is still online — a sitemap, a crawl, a Search Console export. Nothing can reconstruct it afterwards. Guides for a relaunch must say so before the switch, not after.
- A follow-up worth having: the build can report how many known legacy addresses are still unclaimed once that inventory lives in the repository. The number of unmapped addresses is what a site owner actually wants to know, and no individual redirect tells them.
s3-httpneeds redirects evaluated before the file lookup, plus normalisation for letter case and trailing slashes.
See also
- Legacy URLs — the artifact's format
- Site configuration —
retired_urls,aliases,url - ADR 0001 — the ownership pattern this follows