Docs

Build a CMS in C#

Klassd is a code-first, headless CMS for .NET. You define content types — pages, blocks and property types — as plain C# classes; the engine reflects over them to drive a Blazor admin and a JSON delivery API. No schema migrations, no separate modelling UI.

Prefer type-by-type detail? Browse the generated API reference →

Quickstart

Install the engine plus one storage adapter. While Klassd is in beta the packages are prerelease:

dotnet add package Klassd.Backoffice --prerelease
dotnet add package Klassd.Data.Sqlite --prerelease

1. Define content types

Any class deriving from PageBase or BlockBase in your app is discovered automatically:

using Klassd.Core.Abstractions;

[CmsPage(DefaultSlug = "", Icon = "house")]
[AllowedChildren(typeof(ArticlePage))]
public class HomePage : PageBase
{
    [Localized]                          // separate value per locale
    public string Title { get; set; } = "";
    public string SubTitle { get; set; } = "";
    public BlockArea HeroBlocks { get; set; } = new();
}

public class ArticlePage : PageBase
{
    public string Title { get; set; } = "";

    [CmsField(FieldType = "textarea")]
    public string Body { get; set; } = "";

    public MediaReference Hero { get; set; } = new();

    [AllowedRelations(typeof(ArticlePage))]
    public PageReference Related { get; set; } = new();
}

2. Wire it up in Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddKlassd(builder.Configuration)                       // discovers your content types
    .UseSqlite(builder.Configuration.GetSection("Sqlite"))  // or .UseMongoDb / .UsePostgres
    .UseInMemoryCache();                                    // optional read-through cache

var app = builder.Build();
app.UseKlassd();   // auth + antiforgery + seed/init + static assets + /api + Blazor admin
app.Run();

Host .csproj: a host with no .razor files of its own must set <RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>, or /admin 404s on blazor.web.js. (If your host already has .razor files the SDK turns this on for you.)

3. Run

Open /admin to author content. Your frontend reads published content from /api/pages.

Content types

  • Pages — derive from PageBase. Live in a tree (parent/child), have a slug, and are delivered at /api/pages.
  • Blocks — derive from BlockBase. Reusable content components placed into a page's BlockArea properties; each can be scheduled with a publish window.
  • Globals — annotate with [CmsGlobal]. Singletons for site chrome (header, footer) — exactly what this website uses.

Every public property becomes an editable field. The editor for a field is chosen from its CLR type (see below) or an explicit [CmsField(FieldType = "…")].

Built-in property types

Each field maps to a property type by its CLR type, or you can force one with [CmsField(FieldType = "alias")].

AliasCLR typeDeclare itNotes
textstring
public string Title { get; set; } = "";
Default for string. Single-line text input.
textarea— (opt-in)
[CmsField(FieldType = "textarea")]
public string Body { get; set; } = "";
Multi-line text. Opt in on a string via [CmsField].
numberint, long
public int Order { get; set; }
Default for int and long. Numeric input.
checkboxbool
public bool Featured { get; set; }
Default for bool. Toggle.
datetime-localDateTime
public DateTime PublishedAt { get; set; }
Default for DateTime. Date + time picker.
blocksBlockArea
public BlockArea PageBlocks { get; set; } = new();
A named, schedulable area of block instances. Declare one property per area.
mediaMediaReference
public MediaReference Image { get; set; } = new();
Media picker; stores the media item id. A string with [CmsField(FieldType = "media")] also works.
relationshipPageReference
[AllowedRelations(typeof(ArticlePage))]
public PageReference Related { get; set; } = new();
Page picker; stores the target page’s ContentId. Restrict targets with [AllowedRelations]; omit for any page type.
richtext— (opt-in)
[CmsField(FieldType = "richtext")]
public string Body { get; set; } = "";
Rich text editor (Quill); stores HTML.
email / url / color— (opt-in)
[CmsField(FieldType = "email")]
public string Contact { get; set; } = "";
HTML5 email / URL / colour inputs. Opt in on a string via [CmsField].
decimaldecimal, double, float
public decimal Price { get; set; }
Fractional number input.
date / timeDateOnly / TimeOnly
public DateOnly Released { get; set; }
Date-only / time-only pickers.

Media options

Media is organised into named sections, each backed by its own blob adapter (FileSystem / S3 / Google Cloud). Configure with .AddMedia(...):

  • UseFileSystem(path) / UseS3(...) / UseGoogleCloudStorage(...) — pick a backend per section.
  • AllowContentTypes("image/*", "application/pdf") — restrict uploads.
  • ResizeImages(maxEdgePixels) — downscale in the browser before upload.
  • Breakpoints("default", "mobile", "tablet") — focal-point editors the admin offers.

Relationship options

  • Declare a PageReference property (or [CmsField(FieldType = "relationship")] on a string).
  • [AllowedRelations(typeof(ArticlePage), …)] — restrict which page types appear in the picker. Omit for any type.
  • Stores the target's ContentId (locale-agnostic) — resolve from the frontend via GET /api/pages/content/{contentId}, then pick the translation for the locale you're rendering.

Attributes reference

AttributeApplies toOptionsEffect
[CmsPage]page classDefaultSlug, IconPage metadata. DefaultSlug null = auto from name, "" = root. Icon = a built-in icon name ("house", "folder", "file"…) or any emoji; shows in the admin tree.
[CmsGlobal]classMarks a singleton content type (site chrome like a header/footer) rather than a tree page.
[AllowedChildren]page classparams Type[]Restrict child page types. Absent = any; empty = none; with types = only those.
[CmsField]propertyDisplayName, FieldTypeOverride the editor label or force a property type alias (e.g. "textarea", "media", "relationship").
[AllowedRelations]propertyparams Type[]For relationship fields: which page types the picker lists. Absent or empty = any page type.
[Localized]propertyThis field gets a separate value per locale.
[LocalizedPage]page classEvery field on the page is localized.
[Indexable]propertyCreate a database index for this field (cross-adapter).

Editorial features

Beyond the content model, the engine ships the workflow features editors expect:

Drafts & versioning

Edits go to a draft — the live page is untouched until you Publish. Full version history with one-click rollback, plus per-page scheduled publishing.

Roles & permissions

Capability-based roles (Administrator / Editor / Author). Authors save drafts; Editors publish. Enforced on the API and reflected in the admin UI.

Full-text search

Opt-in, storage-agnostic Lucene.NET index — tokenized and ranked, kept live via content events and rebuilt from the database on startup.

Webhooks & notifications

HMAC-signed webhooks for content changes, plus synchronous in-process notifications you can hook to mutate or cancel an operation (before/after publish, save, delete).

GraphQL (opt-in)

A read-only GraphQL delivery API over HotChocolate, mirroring the REST endpoints — added as a package, not in core.

Caching

Read-through page cache: in-process, Redis, or an L1+L2 HybridCache tier.

Extending Klassd

Custom property editors

A custom field editor is a single Blazor component — no JS, no registration. Inherit PropertyEditorBase and mark it with [PropertyEditor("alias")]; the engine discovers it by assembly scan and synthesises the property type for you.

@attribute [PropertyEditor("color")]
@inherits PropertyEditorBase

<input type="color" value="@Value"
       @oninput="e => SetValueAsync(e.Value?.ToString() ?? string.Empty)" />

Then reference the alias from any field:

[CmsField(FieldType = "color")]
public string BackgroundColor { get; set; } = "";

PropertyEditorBase gives you Value (string), ValueChanged and the field metadata. The stored value is always a string — content is persisted DB-agnostically — and MediaReference / PageReference are typed wrappers over that string.

Custom storage & media adapters

Storage and media backends are swappable. The engine depends only on interfaces in Klassd.Abstractions — never a concrete database or cloud SDK. Implement IPageStore / IMediaStore (storage) or IBlobStore (media) and add a UseXxx registration extension. Worked examples ship in the examples/ folder.

Delivery API

The headless GET endpoints are anonymous so a public frontend can read published content (this site consumes them):

  • GET /api/pages — all pages for a locale (?locale=en).
  • GET /api/pages/{id} — a single page.
  • GET /api/pages/by-slug/{**slug} — a page by its slug (?locale=en).
  • GET /api/pages/content/{contentId} — a page and its translations (resolve relationships here).
  • GET /api/pages/{id}/translations — every locale of one page.
  • GET /api/globals/{name} — a global singleton (e.g. SiteHeader).
  • GET /api/media/{id} — a media item's bytes.

Only published, in-window content is delivered. Single-page GETs accept ?depth=1 to resolve PageReference/MediaReference fields to URLs and ?expand= to pick which. Scheduling resolves per request; ?preview=<utc> time-travels delivery when enabled. A /graphql endpoint is available via the opt-in GraphQL package.

API referenceView on GitHubAll NuGet packages

Packages

Install the engine plus the adapters you need — each keeps its SDK isolated, so you only pull in what you wire up. While Klassd is in beta, add --prerelease.

PackagePurpose
Klassd.AbstractionsStorage/media interfaces + DB-agnostic POCOs (no dependencies).
Klassd.CoreContent base types, attributes, registries, localization, default property types.
Klassd.BackofficeThe engine — AddKlassd/UseKlassd, the Blazor admin, and the headless /api.
Klassd.Data.SqliteSQLite storage adapter.
Klassd.Data.PostgresPostgreSQL storage adapter.
Klassd.Data.MongoDbMongoDB storage adapter.
Klassd.Cache.InMemoryIn-process read-through page cache.
Klassd.Cache.RedisDistributed (Redis) read-through page cache.
Klassd.Cache.HybridL1 + L2 cache over Microsoft.Extensions.Caching.Hybrid.
Klassd.Media.FileSystemLocal-disk media blob store.
Klassd.Media.S3Amazon S3 (or S3-compatible) media blob store.
Klassd.Media.GoogleCloudGoogle Cloud Storage media blob store.
Klassd.Auth.OpenIdConnectOIDC/OAuth SSO for the backoffice (SAML via the generic seam).
Klassd.Search.LuceneOpt-in full-text search over Lucene.NET.
Klassd.WebhooksOpt-in HMAC-signed content-change webhooks.
Klassd.GraphQLOpt-in GraphQL delivery API (HotChocolate).

All packages on NuGet

Klassd vs Umbraco vs Payload

How Klassd's code-first, .NET-native approach compares to two popular headless CMSs. All three are open-source (MIT).

FeatureKlassdUmbracoPayload
Code-first schemaC# classesUI / database-drivenTypeScript config
Admin UIBlazor (no JS build)Web-components SPAReact (in your Next.js app)
Drafts & versioningYesYesYes (+ autosave)
Scheduled publishingYesYesYes
Roles & permissionsCapabilities + rolesUser groupsAccess-as-code
Full-text searchLucene.NET (opt-in)Examine / LucenePlugin
Webhooks / eventsWebhooks + notificationsNotificationsHooks
REST deliveryYesYesYes
GraphQLOpt-in packageCloud (Heartcore) onlyCore
Storage backendsMongo / Postgres / SQLiteSQL Server / SQLiteMongo / Postgres / SQLite
LocalizationPer-field + marketsCulture + segment variantsField-level locales
Runtime / platform.NET (Blazor).NET (ASP.NET Core)Node (Next.js)
LicenseMITMITMIT

Klassd's niche: true code-first content modelling in C#, a Blazor admin with no JS build step, and pluggable storage — shipped as NuGet packages you compose. Comparison reflects each project's core/open-source offering as of 2026.