Skip to Content
DocumentationBuild a websiteOverview

Build a website

gtmesh produces a mesh — a committed graph (projected into site.manifest.json) plus per-page content bundles. It does not produce a website. Turning the mesh into a site is the job of a static-site generator (SSG) you own — Next.js, Astro, Eleventy, whatever — and this section is the guide for that build.

The bridge is the content-types package: gtmesh types generates a self-contained TypeScript package your SSG imports. It ships the readers and the config-derived constants an SSG needs, so most of what a site build wants is a function call, not something you re-derive.

The shape of an SSG build

Every mesh-backed site does the same five things. Each maps to something the package gives you:

  1. List what to render — the committed graph (projected into site.manifest.json) is the index of every page that could exist. Filter it by the current environment’s publish status → the render set. pageSlugs() / loadPagesByType() do this.
  2. Render each page — resolve a slug to its content bundle with loadPage<TypePage>(slug), typed by the generated <Type>Page interface.
  3. Wire navigation — breadcrumbs, hub members, related links, facets. The package ships these as feed functions: breadcrumbs(), hubMembers(), relatedLinks(), and entitiesByFacet(), all returning ready-to-render cards.
  4. Emit URLs & redirects — canonical/sitemap/OG URLs from siteBaseUrl(), asset URLs from assetUrl(), 301s from loadRedirects().
  5. Keep it honest — wire gtmesh types --check into CI so the package can never drift from the schemas and config.

Install & wire it up

Generate the package (default packages/content-types/) and reference it as a workspace dependency:

gtmesh types # writes the package + a pnpm workspace + a CI drift gate
// your SSG app's package.json { "dependencies": { "@your-project/content-types": "workspace:*" } }
import { loadPage, loadPagesByType, pageSlugs, // pages loadGraph, loadManifest, // the graph-native reader + the link tree (getById/byLabel/roleOf/edge-walks) breadcrumbs, hubMembers, relatedLinks, // navigation feeds (pass the GraphView) pageSummaries, labelForSlug, // the card index loadEntities, entitiesByFacet, // entities & facets (deprecated → loadGraph) assetUrl, copyMeshAssets, // co-located assets loadRedirects, // 301s SECTIONS, renderableStatuses, viewForEnv, siteBaseUrl, // config-derived constants type GuidePage, type GraphNode, // generated + graph types } from "@your-project/content-types";

The package reads the committed mesh from disk. It resolves the mesh root from CONTENT_ROOT (falling back to process.cwd()), or you pass { root } to any loader. Point it at wherever your build checks out the mesh.

The full function-by-function reference is on the content-types package page. The two pages below cover the parts that trip people up.

Base URLs, sitemaps & canonical tags

Every canonical, sitemap, and Open Graph URL is built from one origin — the site’s base URL. Declare it once so the mesh owns it and prose, the writer, and the SSG can’t disagree. Set it with gtmesh config rather than hand-editing the YAML — the writer is comment-preserving and validates before it saves:

gtmesh config set site.base_url https://example.com gtmesh config set environments.dev.base_url https://staging.example.com # optional per-env override

which lands as:

site: base_url: https://example.com environments: prod: { renders: [published] } dev: { renders: [writing, review, published], base_url: https://staging.example.com }

Then regenerate the package (gtmesh types) so the value flows into site.ts, and read it with siteBaseUrl(env) — the env-specific override if set, else the committed default:

const origin = siteBaseUrl(process.env.DEPLOY_ENV) ?? "http://localhost:3000"; const canonical = new URL(page.slug, origin).toString();

A sitemap is then pageSlugs({ status: renderableStatuses(env) }) mapped through that origin. A robots.txt or llms.txt is the same slug set.

site.base_url is the committed default. An SSG deploying to an ephemeral preview URL can still override at the edge with its own runtime env var — the config value is the source of truth, not a straitjacket.

Last updated on