Skip to Content

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 url node{ id: "url:<old-slug>", labels: ["url"], props: { slug: "<old-slug>", status: "alias" } } (the url: prefix namespaces it so it can never collide with a page node).
  • An ALIAS_OF edge 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 url node 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 build projects the url/ALIAS_OF layer into site.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:

  1. A rename group-edit in overrides/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)

    plan shows redirect: 1 … slug-change (review); apply writes /glossary/what-is-a-webhook → /glossary/http-webhook as a url/ALIAS_OF alias.

  2. A parent_topic change — 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 url node for the old slug + an ALIAS_OF edge to the live page node.
  • Moves the page bundle from content/<old-slug>/ to content/<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 301

Any 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:

  1. 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).
  2. The mesh’s own redirects — slugs the mesh itself later moves (/b → /c), from loadRedirects().

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

ThingWhat it is
graph/{nodes.jsonl,edges.tsv}url nodes + ALIAS_OF edges — the redirect state
site.manifest.jsonThe projected { from, to } redirect table the SSG reads
overrides/group-edits.yaml renamesThe lever that moves a committed URL (→ a redirect hop)
loadRedirects()The generated typed reader (gtmesh types); reads the manifest redirect table
Last updated on