Skip to content

Blog — the editorial vertical on the CMS

Granit.Blog is an editorial blog vertical that runs on the Granit CMS. It adds a BlogPost aggregate — copy-on-write versioned, workflow-published, scheduled, authored, and illustrated — while deliberately staying decoupled from the CMS: the base package consumes only Granit.Cms.Abstractions (the content seam) and never references Granit.Cms. One package, Granit.Blog.Cms, opts the vertical into the full CMS (content type, Releases, SEO, Puck blocks).

  • Granit.Blog — the base vertical. The BlogPost aggregate with copy-on-write versioning (BlogPostVersion + per-culture BlogPostVersionContent stored as jsonb), the publication workflow (Draft → PendingReview → Published → Archived on Granit.Workflow), PublicationSchedule, BlogAuthorProfile, and attachments. It depends only on Granit.Cms.Abstractions — the content seam — and never references Granit.Cms.
  • Granit.Blog.EntityFrameworkCore — the BlogDbContext (a GranitDbContext: multi-tenant, audited, soft-delete, copy-on-write), entity configurations, readers/writers, a sitemap source, and indexing. Wired by the host through AddGranitBlogEntityFrameworkCore. Provider-agnostic and ships no migrations — the consuming app owns them.
  • Granit.Blog.Endpoints — the Minimal API. Admin post authoring (CRUD + media gallery + draft content), the publication lifecycle (publish / unpublish / schedule / cancel), author-profile CRUD, and the anonymous public read surface the Next.js front consumes (get-by-slug, a paginated chronological listing, an RSS 2.0 feed). Mapped by the host with MapGranitBlog().
  • Granit.Blog.Documents — an optional adapter. An IBlogPostMediaResolver that resolves a post’s cover + gallery document references into render-ready Web renditions (stable public-link URLs + dimensions + MIME) via Granit.Documents resolution. Register it with AddGranitBlogDocuments — reference it only when the host wires Granit.Documents.
  • Granit.Blog.Cms — the only Blog package that references the CMS. It registers blog.post as a content type and a release publisher (posts join CMS Releases), a Puck-aware plain-text extractor (feeds the block body to the search index), a schema.org BlogPosting JSON-LD builder, a sitemap provider with hreflang alternates (Granit.Cms.Seo), and an editor-agnostic data-bound blog-latest-posts Puck block + data source. Wired with AddGranitBlogCms.

A BlogPost is stable identity; its content and publication state live on immutable BlogPostVersion rows. There is one lineage per postIVersioned.VersionId equals the BlogPost.Id, so every version belongs to exactly one post — and history is append-only.

stateDiagram-v2
    [*] --> Draft: create / rollback
    Draft --> PendingReview: SubmitForReview()
    PendingReview --> Published: Approve() / Publish()
    Published --> Archived: superseded by next publish
    Published --> Archived: Unpublish()
  • At most one Published version at a time. Publishing a new version supersedes the current published one and archives it.
  • Draft content is upserted in place (BlogPostVersionContent per culture); publishing freezes it into the published snapshot.
  • A rollback forks a fresh draft from any historical version — the prior history is never mutated.
BlogPost post = BlogPost.Create(id, siteId, slug: "hello-world", tenantId);
post.UpsertDraftContent("en", contentJson, title: "Hello, world"); // draft only
post.SubmitForReview();
post.Publish(timeProvider); // supersedes + archives the prior Published version

Lifecycle transitions run on Granit.Workflow, so the Draft → PendingReview → Published → Archived path is a declared state machine, not ad-hoc flags. A PublicationSchedule arms a future publish at a wall-clock time; posts can also join a CMS Release (via Granit.Blog.Cms) to go live in a batch with pages.

BlogPost and BlogPostVersionContent are IConcurrencyAware. The update and draft-save endpoints take a ConcurrencyStampBlogPostUpdateRequest implements IConcurrencyStampRequest — and a stale stamp yields HTTP 409, so two editors saving the same post (or the same culture’s draft) in parallel don’t silently clobber each other.

BlogPostVersionContent.ContentJson is an opaque serialized block tree. The backend never parses it, never names the editor, and never couples to a block schema — it just stores and serves the JSON. Only Granit.Blog.Cms adapts Puck JSON, and it does so behind the editor-agnostic IBlogPostContentTextExtractor (which yields the plain text the search index needs). Swap the editor and the base vertical is untouched.

Every Blog entity carries a SiteId and a TenantId. SiteId is the multi-site discriminator — it sits on top of tenancy — while TenantId stays the GDPR boundary. One tenant runs N sites; site-scoped query filters keep one site’s posts invisible to another, and tenant isolation remains the compliance edge. This mirrors the CMS core.

Post slugs are lowercase-kebab, validated by the framework’s .Slug() rule (Validation:Format:Slug). See Validation for the shared format rules and their localized messages.

All under the Blog permission group:

PermissionGrants
Blog.Posts.ReadRead posts (admin surface)
Blog.Posts.ManageAuthor, edit, save drafts, manage the media gallery (own scope)
Blog.Posts.ManageAnyLifts the own-vs-any ownership scope to any post
Blog.Posts.PublishPublish / unpublish / schedule — a distinct least-privilege action
Blog.Authors.ReadRead author profiles
Blog.Authors.ManageCreate / edit author profiles

MapGranitBlog() mounts the whole surface. Admin routes are permission-gated; the public read surface the Next.js front consumes is anonymous and site-scoped.

Route groupAuthPurpose
Admin post authoring — CRUD, media gallery, draft contentBlog.Posts.Read / ManageAuthor and edit posts
Publication lifecycle — publish / unpublish / schedule / cancelBlog.Posts.PublishMove a post through the workflow
Author-profile CRUDBlog.Authors.Read / ManageManage bylines
Public — get-by-slug, paginated chronological listing, RSS 2.0 feedanonymous, site-scopedThe read surface the front renders

OpenAPI groups the routes under three tags — Blog - Posts, Blog - Authors, and Blog - Feed — each overridable via BlogEndpointsOptions.{TagName, AuthorsTagName, FeedTagName}.

BlogEndpointsOptions has no SectionName — it is configured through the MapGranitBlog delegate:

app.MapGranitBlog(options =>
{
options.DefaultPageSize = 20; // listing page size
options.MaxPageSize = 100; // listing clamp
options.FeedItemCount = 20; // items in the RSS feed
options.FeedTitle = "Blog"; // feed <title>
// options.TagName / AuthorsTagName / FeedTagName — override OpenAPI tags
});

BlogCmsOptions (the CMS glue) binds from configuration section Blog:Cms.

builder.AddGranitBlogEntityFrameworkCore(options => options.UseNpgsql(cs));
builder.AddGranitBlogDocuments(); // optional — only if Granit.Documents is wired
builder.AddGranitBlogCms(); // only when running inside a Granit CMS host
// ...
app.MapGranitBlog();

AddGranitBlogEntityFrameworkCore is the minimum. Add AddGranitBlogDocuments() when the host wires Granit.Documents and you want cover/gallery renditions; add AddGranitBlogCms() only inside a CMS host to register the content type, release publisher, JSON-LD, sitemap, and the blog-latest-posts block.

  • Metrics — meter Granit.Blog emits granit.blog.post.published, granit.blog.post.unpublished, and granit.blog.post.scheduled. Tag keys are snake_case and include tenant_id (coalesced to "global") and site_id.
  • TracingActivitySource Granit.Blog.
  • Localization — every message is localized across all 18 cultures (BlogResource).
  • Sites & Pages — the CMS content core and the copy-on-write pattern this vertical mirrors
  • Releases — batch/scheduled publishing that posts join via Granit.Blog.Cms
  • SEO — sitemaps and JSON-LD the CMS glue plugs into
  • Blocks — where the blog-latest-posts data-bound block registers
  • Media References — the document resolution Granit.Blog.Documents builds on
  • Workflow — the state machine behind the publication lifecycle
  • Validation — the .Slug() rule and shared format validation