Redirects
When a page’s URL changes, the old URL must keep working — forever. GoToMesh
records every URL move as graph state — a url node with an ALIAS_OF edge to the live page — and
your site reads it to emit permanent (301) redirects.
What it is
Redirects live in the graph:
- The old URL becomes a
urlnode —{ id: "url:<old-slug>", labels: ["url"], props: { slug: "<old-slug>", status: "alias" } }(theurl:prefix namespaces it so it can never collide with a page node). - An
ALIAS_OFedge points that url node at the live page node it redirects to.
Because ALIAS_OF targets the page node (a stable id), not a destination slug, a url always
resolves to its page’s current slug. Three properties make this safe to serve straight to a router:
- Durable. The
urlnode stays in the graph forever, so an old URL resolves years later. - Chain-collapse is intrinsic. Rename a page twice and both old URLs resolve to the newest slug — there are never redirect chains to follow, because the alias points at the node, not a stale slug. A rename back to a prior slug drops the now-self redirect automatically.
- A manifest query.
gtmesh buildprojects theurl/ALIAS_OFlayer intosite.manifest.json’s redirect table as a flat{ from, to }list — your SSG reads that.
What creates a hop
A row is created when a committed page’s slug changes. plan surfaces that as a human-gated
redirect action (reason slug-change), and apply records the hop (and moves the page bundle, below).
Routine work never creates a redirect. A committed slug is frozen (see slug anchoring & freeze): re-pulling data, a higher-volume head keyword taking the lead, relinking, re-projecting the manifest — none of them move a URL. Only a deliberate edit does. This is the whole point of the freeze: your URLs don’t churn under you.
There are exactly two deliberate levers that move a committed URL:
-
A
renamegroup-edit inoverrides/group-edits.yaml— pins a new slug without changing the page’s identity. This is the normal way to fix a confusing or awkwardly-frozen URL:# overrides/group-edits.yaml renames: - from: webhook # the group's current parent_topic to: http-webhook # the new slug stem (the section prefix is preserved)→
planshowsredirect: 1 … slug-change (review);applywrites/glossary/what-is-a-webhook → /glossary/http-webhookas aurl/ALIAS_OFalias. -
A
parent_topicchange — changing the identity’s topic re-slugs the page, which flows through the same slug-change → redirect path.
Because these are human edits surfaced as a review action (CI flags the plan with a non-zero exit), a URL never moves by accident.
How apply enacts it
On a slug change, apply does two things atomically — it’s a mutation at apply, not a plan-time diff:
- Mints the alias — a
urlnode for the old slug + anALIAS_OFedge to the live page node. - Moves the page bundle from
content/<old-slug>/tocontent/<new-slug>/, so the written body and its co-located assets follow the page to its new path — no orphaned folder, no re-scaffold. (A page that was only catalogued has no bundle yet, so there’s nothing to move — just the alias.)
Commit the graph change alongside the rest of the apply output.
Should you ever hand-edit it?
No — treat redirect state like the rest of the committed graph. It’s computed; apply is its only
writer. To move a URL, make the source edit (a rename group-edit) and let apply mint the alias — a
hand-edit either gets reworked on the next apply or drifts from the graph.
Wiring it into your site
Your SSG (or router) reads the redirect table and emits a permanent (301) redirect per entry. The generated
content-types package (gtmesh types) gives you a
typed loadRedirects() reader — it reads site.manifest.json’s redirect table — so you don’t touch the manifest yourself:
import { loadRedirects } from "@your-mesh/content-types"; // generated by `gtmesh types`
const redirects = loadRedirects(); // → [{ from: "/old", to: "/new" }, …]Then feed that into whatever your platform uses for redirects. Because the ledger is already
chain-collapsed, each from maps to its final to in one hop — emit them verbatim.
Next.js (next.config.js):
const { loadRedirects } = require("@your-mesh/content-types");
module.exports = {
async redirects() {
return loadRedirects().map(({ from, to }) => ({
source: from,
destination: to,
permanent: true, // 301
}));
},
};A static host (Netlify / Cloudflare Pages _redirects) — generate the file at build time:
# _redirects (one line per ledger row; 301 = permanent)
/glossary/what-is-a-webhook /glossary/http-webhook 301
/integrations/slack-app /integrations/slack 301Any server / middleware — look the incoming path up in a Map built from the ledger and issue a
301 when it’s found:
const table = new Map(loadRedirects().map((r) => [r.from, r.to]));
// in the request handler:
const to = table.get(req.path);
if (to) return res.redirect(301, to);Always emit 301 (permanent), not 302. A GoToMesh redirect is a permanent fact — the old URL has moved for good — so search engines should transfer ranking to the new URL. The ledger only ever contains permanent moves.
Migrating from an existing site
Moving an existing site onto the mesh gives you two redirect sources, and they compose:
- Your migration map — old external URLs → their new mesh slugs (
/a → /b). The mesh never knew/a, so this is yours to own: keep it in your Next.js config (or host), not in the mesh’s own redirect state (that’d be hand-editing computed output). - The mesh’s own redirects — slugs the mesh itself later moves (
/b → /c), fromloadRedirects().
Wire both in and a request to /a resolves /a → /b → /c. It works — but that’s a two-hop chain
(two 301s), and a round-trip you can avoid.
Collapse it to one hop by resolving your migration targets through the ledger at build time.
Because the ledger is chain-collapsed, a single lookup returns the current slug — so /a lands on /c
directly, and your one-time /a → /b map keeps pointing at wherever /b lives, with no maintenance
even as the mesh keeps moving slugs:
const { loadRedirects } = require("@your-mesh/content-types");
const ledger = loadRedirects(); // the mesh's own moves: [{ from, to }]
const current = new Map(ledger.map((r) => [r.from, r.to]));
const resolve = (slug) => current.get(slug) ?? slug; // ledger is collapsed → one lookup
const migration = [{ from: "/a", to: "/b" }]; // your old→mesh map (write once)
module.exports = {
async redirects() {
return [
// migration, resolved to the CURRENT slug → /a lands on /c in ONE hop
...migration.map((m) => ({ source: m.from, destination: resolve(m.to), permanent: true })),
// the mesh ledger too, so a direct hit on an old mesh slug also resolves
...ledger.map((r) => ({ source: r.from, destination: r.to, permanent: true })),
];
},
};Now /a → /c (one hop) and /b → /c (for anyone landing on the old mesh slug directly). If the mesh
later moves /c → /d, the ledger collapses /b → /d and /a follows automatically — you never touch
the migration map again.
Key files
| Thing | What it is |
|---|---|
graph/{nodes.jsonl,edges.tsv} | url nodes + ALIAS_OF edges — the redirect state |
site.manifest.json | The projected { from, to } redirect table the SSG reads |
overrides/group-edits.yaml renames | The lever that moves a committed URL (→ a redirect hop) |
loadRedirects() | The generated typed reader (gtmesh types); reads the manifest redirect table |
Related
- The mesh & links — up-links, siblings, and where redirects sit
- The mesh store — the committed state the redirects accompany
- Slug anchoring & freeze — why routine work never moves a URL
- Lifecycle & reconcile — the
redirectreconcile action - Content-types package — the reader your SSG compiles against