Skip to Content
DocumentationBuild a websiteNavigation & relationships

Navigation & relationships

This is where most of a site build’s logic lives, and where the mesh’s model is least obvious. The package ships the traversal as feed functionsbreadcrumbs, hubMembers, relatedLinks, labelForSlug, and the entities/facet reader — so most of this is a call, not code you write. Three things still trip people up; get them right and the rest is mechanical.

1. A page’s entity key is not its URL slug

A category page at /categories/rotary-lobe-pumps (plural, a URL) has entity key rotary-lobe-pump (singular, an identity). The mapping is the graph’s DERIVED entityOf — never the URL segment. A naive match of a slug segment against entity keys returns nothing.

const g = loadGraph(); const node = g.bySlug("/categories/rotary-lobe-pumps")!; g.entityOf(node.id); // "rotary-lobe-pump" ← the identity // pages that resolve to this entity: g.byLabel("page").filter((n) => g.entityOf(n.id) === g.entityOf(node.id));

Whenever you need “the thing this page is about,” read entityOf(id). The slug is for routing; the entity is for relationships.

2. Membership is two different mechanisms

“What belongs under this hub” has two answers depending on the relationship, and they come from two different places. This is the single biggest source of “where does this come from” in a mesh build.

A category’s products, a manufacturer’s models — the members of a hub — are the inverse of the up-links. hubMembers returns them as ready-to-render cards (label, slug, thumbnail), sorted, pruned to the render set:

const g = loadGraph(); hubMembers(g, "/categories/rotary-lobe-pumps", { status: renderableStatuses(env) }); // → PageSummary[] — the pages that link up to this hub

Under the hood that’s the derived down edge (the reverse of the view’s up in site.manifest.json). For the topical members of a hub’s owned topic, use the graph directly: g.membersOf(hubId). See The mesh & links for how up-links are derived in the first place.

Facets → the entities table (a project-declared relationship)

A product’s industries, applications, collections — its facets — are a different kind of relationship: many-to-many attributes of an entity, not a hub it funnels into. These are not in the up-link mesh at all. They are facet memberships your project declares under taxonomy.facets (carried as facet props on the entity nodes in the graph). A facet hub’s up-links are empty; its membership is “every entity whose facet column contains this value” — which is entitiesByFacet:

const entities = loadEntities(); // the members of a facet hub (every product serving an industry): entitiesByFacet(entities, "industry", "dewatering"); // → Entity[] // the facets OF one entity (for its chips/attributes): entityFacets(entityBySlug(entities, "rotary-lobe-pump", ENTITY_KEY)!, FACET_AXES); // → { industry: ["dewatering", "hvac"], application: [...] }

Which columns are facets is config-declared (taxonomy.facets), not a fixed gtmesh schema — the generated FACET_AXES constant carries the list, so entityFacets and your facet routes can’t drift from config. The rule of thumb stays: belongs-to = mesh links (hubMembers); facet = an entities column (entitiesByFacet).

3. Labels live in the bundle, not the graph

The committed graph carries a page’s slug but not its display name. Editorial meta — navTitle, title, metaDescription — lives in the page’s content bundle (content/<slug>/index.yaml), because meta is split: the engine owns identity, the writer owns editorial copy. So labelling a related slug means resolving that target’s card. The page-summary projection does this once and indexes it:

const g = loadGraph(); labelForSlug(g, "/categories/rotary-lobe-pumps"); // → "Rotary Lobe Pumps" (navTitle ?? title ?? humanised slug) // or the whole card — label, description, thumbnail — for a tile: const card = pageSummary(g, slug); // { label, description, thumbnail, … } // build the full index up front for a large build: const cards = pageSummaries(g, { status: renderableStatuses(env), thumbnails: THUMBNAILS });

pageSummaries scans every bundle exactly once and indexes it in memory, so every related/breadcrumb/hub-member label is an O(1) lookup instead of re-parsing the same target many times per build. It’s memoised on the GraphView, so the feed functions (breadcrumbs, hubMembers, relatedLinks) all share the one scan — they return PageSummary[] already. The graph stays content-free; the card index is the sanctioned place for display fields, not new node props.

Thumbnails are config-driven: pass { thumbnails: THUMBNAILS } and each card carries a normalized thumbnail ({ asset, alt?, caption?, ratio? }) resolved from the bundle path your mesh declares (site.thumbnail, content.thumbnail by default, per-page-type overridable). asset is the bare co-located filename — resolve its URL with assetUrl(slug, asset).

The link tree lives in site.manifest.json, one entry per page — read it with loadManifest().pages[slug] (the feed helpers wrap this, so you rarely read it directly). Each entry carries the derived edges (snake-cased on disk). What each means, and when to use it from an SSG:

Each page carries a link block per viewlive (published-only, for prod) and preview (writing/review/published, for local/dev) — and each block resolves the same four scopes. A build runs in one view, so you set it once and read links without branching:

// set the view once (from your env) — every page.links.* is then that view's links const pages = loadPages({ view: viewForEnv(env) }); pages[0].links.up; // the up-links for THIS build — no live/preview logic in your templates pages[0].links.siblings; // …and siblings / across

The feed helpers (breadcrumbs, hubMembers, relatedLinks) do the same under the hood, so you rarely read loadManifest().pages[slug] directly.

ScopeWhat it links toHow it’s resolved
upThe apex (hub / pillar) of every topic the page is a member of — authority routing, spoke → apexNearest apex owner (HUB_OF/PILLAR_OF) among pages present in the view; informational pages prefer the pillar, commercial take both; capped by links.up
down (derived)The page’s children — the spokes that route up to itThe pure inverse of the view’s up (via hubMembers / pageLinks(...).down); never stored
siblingsThe most topically-related peers — “more like this”Same section, sharing ≥1 topic, ranked by shared-topic weight; capped by links.siblings.limit
acrossThe other page-types’ treatments of the page’s topics — cross-formatDifferent page_type, sharing a topic the target primarily treats (owns as apex, or its parent_topic confers); ranked by shared-topic weight; capped by links.across.limit

across vs. siblings — the distinction. siblings are peers in the same kind of content (other guides that share topics). across is the same subject in a different format — for the topic “centrifugal pumps”, the guide, the glossary entry, and the comparison page all link to each other via across, and every product in the topic reaches them too. Siblings = same section + shared topics; across = a shared topic, different format — where the target treats that topic as its primary subject.

live vs. preview — the two views. Every page’s links are resolved twice. live points only at published pages (what a production build links to — never a dead link); preview points at everything that renders locally (writing/review/published), so you see the mesh wired up before anything ships. An SSG reads the block for its build env. A link whose target isn’t present in the build’s view degrades to plain text (the anchor stays, the href drops), so publishing a page ahead of its neighbours never breaks the build — and when the neighbour publishes, the link lights up on the next zero-rewrite manifest re-projection. (The planning view — the full catalogued graph including not-yet-drafted pages — lives in the committed graph and is what gtmesh ui shows; it is deliberately not in the site manifest.)

A breadcrumb is the path from an apex hub down to the page. breadcrumbs walks the primary up-link chain and returns each hop as a card, root → page:

const g = loadGraph(); breadcrumbs(g, slug).map((c) => ({ href: c.slug, label: c.label })); // "related" / "see also" rails — siblings (shared domain axis) + across (cross-axis): const { siblings, across } = relatedLinks(g, slug, { status: renderableStatuses(env) });

breadcrumbs follows the preview view’s up-links (the widest structural path — it exists before every hub publishes); with opts.status it stops climbing at the first non-renderable ancestor, so a production crumb never links to a hidden hub.

Some pages have two parents (a brand-vs-brand comparison links up to both brand hubs — a dual parent). breadcrumbs takes the primary (first) parent for the crumb; expose the rest as related links. If you need both branches, read the full up array for the view: pageLinks(loadManifest(), slug, viewForEnv(env)).up.

Last updated on