Skip to content

Sites & Pages — the content core

Granit.Cms (core) owns the content model: sites, a page tree, versioned page content, and menus. It persists through an isolated, tenant- and site-scoped CmsDbContext (tables prefixed cms_) and exposes everything under /api/cms via MapGranitCms().

A Site is one website within a tenant. It pins the cultures the site serves, its default theme, and which page answers /.

Site site = Site.Create(
id: siteId,
slug: "acme-clinic", // kebab-case, unique per tenant
defaultCulture: "en",
allowedCultures: ["en", "fr"], // default must be in the set
tenantId: tenantId);
site.SetHomePage(homePageId); // which page is served at "/"
site.Activate(); // IActive flag, distinct from soft-delete

Site is ITranslatable<SiteTranslation> (per-culture display name) and IActive (Activate()/Deactivate()). The legacy Domains list is superseded by managed custom domains.

RoutePermissionPurpose
GET /api/cms/sites · GET /sites/{id}Cms.Sites.ReadList / read
POST /sites · PUT /sites/{id} · DELETE /sites/{id}Cms.Sites.ManageCreate / update / soft-delete
PUT /sites/{id}/home-page · DELETE …/home-pageCms.Sites.ManageSet / clear homepage

Pages form a per-site tree stored on a materialized path. Page is structure — its content lives on versions (next section). Each page has a culture-invariant SlugSegment; the public URL slug is per-culture on PageTranslation.

Page root = Page.CreateSiteRoot(rootId, siteId, tenantId); // the invisible "/" root
Page about = Page.Create(aboutId, parent: root, slugSegment: "about");
about.SetTranslation("en", urlSlug: "about-us", title: "About us");
about.SetTranslation("fr", urlSlug: "a-propos", title: "À propos");
about.MoveTo(newParent); // reparent within the same site; cycles rejected

The framework maintains StructurePath (e.g. /about/team) and Depth; renaming or moving a page rewrites its descendants’ paths. Database invariants enforce the shape: one IsSiteRoot per site (partial unique index), unique SlugSegment among siblings, and a check constraint that every non-root page has a parent.

RoutePermissionPurpose
GET /pages · GET /pages/tree · GET /pages/{id}Cms.Pages.ReadList / tree / read
POST /pages · PUT /pages/{id} · POST /pages/{id}/moveCms.Pages.ManageCreate / rename / reparent
PUT /pages/{id}/translations/{culture}Cms.Pages.ManageUpsert per-culture slug + title
DELETE /pages/{id}Cms.Pages.ManageSoft-delete

Content and publication state live on PageVersion, not Page. A version holds per-culture content payloads (PageVersionContent, the opaque Puck JSON stored as jsonb) and moves through a lifecycle: Draft → Published → Archived.

stateDiagram-v2
    [*] --> Draft: CreateDraft / rollback
    Draft --> Published: Publish()
    Published --> Archived: superseded by next publish
    Published --> Archived: Unpublish
PageVersion draft = PageVersion.CreateDraft(versionId, pageId, siteId, tenantId);
draft.UpsertContent("en", contentJson, title: "About us"); // only allowed in Draft
draft.Publish(timeProvider); // stamps PublishedAt

Guarantees enforced in the database:

  • At most one Published version per page (filtered unique index). Publishing a new version archives the prior published one.
  • One content payload per culture per version (unique (PageVersionId, Culture)).
  • Optimistic concurrency on content: two editors saving the same culture in parallel — the second gets a 409 via the ConcurrencyStamp.

A rollback forks a fresh draft from any historical version’s snapshot — history is never mutated.

RoutePermissionPurpose
PUT /pages/{id}/draft/{culture}Cms.Pages.ManageSave draft content (jsonb)
GET /pages/{id}/versionsCms.Pages.ReadVersion history
POST /pages/{id}/publish · …/unpublishCms.Pages.PublishPublish / archive
POST /pages/{id}/rollback/{versionId}Cms.Pages.PublishFork a draft from a snapshot

The renderer resolves pages anonymously, scoped to the current site (the SiteResolutionMiddleware reads the site from the request — see custom domains). Drafts never resolve through the public path.

RouteAuthPurpose
GET /pages/by-path?culture=…&path=…anonymous, site-scopedResolve the published page for a URL
POST /pages/{id}/preview-tokenCms.Pages.ManageMint a signed, short-lived preview token
GET /preview/resolve?token=…anonymous, token-gatedResolve a draft for the preview front
GET /search · GET /pages/searchanon / Cms.Pages.ReadPublic / admin page search

Preview is a signed HMAC token over a draft; the front enters Next.js draft mode and renders the draft without ever reading or writing the ISR cache, so unpublished content can’t leak into the public cache.

So two editors don’t silently clobber each other, the core tracks who is editing a page (heartbeat presence, scoped to tenant + site and gated by Cms.Pages.Read): POST /pages/{id}/editing/heartbeat, GET /pages/{id}/editing, DELETE /pages/{id}/editing.

A Menu is a named, ordered tree of links per site (Key unique per site). The whole MenuItem tree is stored as a single jsonb column and replaced copy-on-write — one save writes the whole tree (max depth 5).

Menu menu = Menu.Create(id, siteId, key: "main-nav", title: "Main navigation");
menu.ReplaceItems(items); // validates depth and target invariants

Each MenuItem carries a Label, a validated Target, optional Children, an IsVisible flag (hidden items + subtrees are pruned at resolve time), and renderer hints (Icon, CssClass).

RoutePermissionPurpose
GET /menus · GET /menus/{id}Cms.Menus.ReadList / read
POST /menus · PUT /menus/{id} · DELETE /menus/{id}Cms.Menus.ManageCreate / update / soft-delete
GET /menus/resolve?siteId=…&key=…&culture=…anonymousRender-ready, visibility-pruned tree
  • Blocks — what fills a page’s content
  • Releases — publish many pages at a scheduled time
  • SEO — per-page metadata over this content
  • Redirects — auto-created when a page moves