Skip to content

CMS — a multi-site, block-based content backend

Granit.Cms is a headless, multi-site CMS built on the Granit framework. The backend owns content, structure, versioning, and SEO; a Next.js App Router front renders pages server-side with ISR. Editors compose pages from blocks in a Puck editor — the backend stores the block tree as opaque JSON and never parses it, so the renderer and the editor evolve without backend changes.

It is delivered as the Granit.Cms.* package family (shipped from the granit-website repo, consumed via NuGet), each capability an opt-in module with the usual Granit layer split (.EntityFrameworkCore, .Endpoints, .BackgroundJobs).

CapabilityModuleWhat it gives you
Sites & pagesGranit.CmsMulti-site tree, copy-on-write page versions, draft→publish, i18n routing, preview, menus
BlocksGranit.CmsSelf-describing block registry + catalog, server/client render, data-bound blocks
ReleasesGranit.CmsSchedule a batch of publish/unpublish actions at a wall-clock time
SEOGranit.Cms.SeoCascading metadata, sitemap/robots/manifest, JSON-LD — plus AI-suggested titles & descriptions
RedirectsGranit.Cms.RedirectsPer-site 301/302/307/308 with prefix matching, loop-safe chains, auto-redirect on page move
Custom domainsGranit.Cms.HostnamesMap a verified hostname to a site (DNS challenge + certificate lifecycle)
Media referencesGranit.Cms.DocumentsResolve documents embedded in blocks into stable, renewable asset URLs
flowchart LR
    Editor["Puck editor (front)"] -->|draft JSON| API["Granit.Cms API"]
    API -->|jsonb| DB[("Postgres<br/>cms_* tables")]
    Renderer["Next.js SSR/ISR"] -->|by-path, published| API
    Visitor["Visitor"] -->|hostname| Renderer
    API -. resolves .-> SEO["Granit.Cms.Seo"]
    API -. resolves .-> RD["Granit.Cms.Redirects"]
    API -. resolves .-> HN["Granit.Cms.Hostnames"]

Two design rules shape everything:

  • SiteId is the multi-site discriminator; TenantId is the GDPR boundary. Every CMS entity carries both. A tenant can run N sites; site-scoped query filters keep one site’s content invisible to another, while tenant isolation stays the compliance edge.
  • Copy-on-write versioning. A Page is stable structure; its content and publication state live on immutable PageVersion rows (Draft → Published → Archived). No event sourcing — just a new draft forked from any prior version. See Sites & Pages.

Each capability is a module you depend on, an EF Core registration, and a Map call. The core is the minimum; add satellites as needed.

// Module graph (DependsOn on your app module)
[DependsOn(
typeof(GranitCmsModule),
typeof(GranitCmsEntityFrameworkCoreModule),
typeof(GranitCmsEndpointsModule),
typeof(GranitCmsBackgroundJobsModule))]
public class AppModule : GranitModule { }
// Program.cs — persistence (isolated, tenant + site aware DbContext)
builder.AddGranitCmsEntityFrameworkCore(db => db.UseNpgsql(connectionString));
builder.AddCmsSeoEntityFrameworkCore(db => db.UseNpgsql(connectionString)); // optional
builder.AddCmsRedirectsEntityFrameworkCore(db => db.UseNpgsql(connectionString)); // optional
builder.AddGranitCmsDocumentsEntityFrameworkCore(db => db.UseNpgsql(connectionString)); // optional
// Map the HTTP surface
app.MapGranitCms(); // sites, pages, blocks, menus, releases, routing, preview
app.MapGranitCmsSeo(); // sitemap/robots/manifest + metadata admin
app.MapGranitCmsSeoAI(); // AI suggestion inbox
app.MapGranitCmsRedirects();
app.MapGranitCmsSiteHostnames();
app.MapGranitCmsDocuments(); // anonymous asset redirect endpoint

These are settled in the backend and reflected throughout the docs:

  1. Editor: Puck (JSON block tree) + TipTap rich text inside blocks.
  2. Renderer: Next.js App Router, SSR + ISR (tag-based cache invalidation).
  3. Multi-site: N sites per tenant; SiteId on every entity.
  4. i18n: sub-path routing (/fr/, /en/), per-culture URL slugs.
  5. Versioning: copy-on-write per page + audit interceptor. No event sourcing.
  6. Preview: signed HMAC token on a draft; never touches the ISR cache.
  7. Blocks: server-rendered by default; client only when declared.
  8. Persistence: Postgres-first, jsonb for serialized block content.