Klassd is in public beta (
0.0.x). It builds, is covered by unit/integration/UI tests, and runs — but it's young: the API surface may change between releases until1.0, and you may hit rough edges. Pin your package versions, read the release notes when upgrading, and please open an issue for anything that looks off. Not yet recommended for production without your own evaluation.
A code-first, NuGet-distributed headless CMS for .NET. You define your content model —
pages, blocks and property types — as plain C# classes. The engine reflects over them to
drive a Blazor (Interactive Server) admin at /admin and a headless JSON API at /api.
No content-type designer, no database migrations to hand-write, no JavaScript build step.
Your content schema lives in your codebase, versioned with your app and refactored with your IDE.
- Code-first — content types are C# classes; rename a property in your IDE, not a CMS UI.
- Headless — public JSON delivery API; render with any frontend (or none).
- Pluggable storage — MongoDB, PostgreSQL or SQLite via a single
.UseXxx(...)call. - Pluggable media — file system, Amazon S3 or Google Cloud Storage, with named sections.
- Localization built in — per-locale fields via
[Localized], market-local scheduling. - No JS toolchain — the admin is Blazor; cloud SDKs stay isolated in their own packages.
The Blazor admin at /admin is generated from your C# content types — no JavaScript build step,
no separate schema to maintain. Pages, blocks, fields, media and localization all come from your code.
| Sign in | Page tree |
|---|---|
![]() |
![]() |
| Page editor (fields + blocks) | Media library |
![]() |
![]() |
| Users | Dictionary |
![]() |
![]() |
…and the same shell in dark mode (toggled per user, persisted to preferences):
| Page tree | Page editor | Media |
|---|---|---|
![]() |
![]() |
![]() |
The page editor above is fully driven by the HomePage/HeroBlock C# classes — the Title field,
the Hero Blocks area, and per-block scheduling are all reflected from your model.
Install the engine plus one storage adapter. While Klassd is in beta the packages are
prerelease, so pass --prerelease (or pin an explicit version):
dotnet add package Klassd.Backoffice --prerelease
dotnet add package Klassd.Data.Sqlite --prerelease1. Define content types as C# classes (discovered automatically from your app's assembly):
using Klassd.Core.Abstractions;
[CmsPage(DefaultSlug = "", Icon = "house")] // Icon shows in the admin tree (built-in name or any emoji)
[AllowedChildren(typeof(ContentPage))]
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 HeroBlock : BlockBase
{
public string Heading { get; set; } = "";
[CmsField(FieldType = "media")] // media picker; stores the media item id
public string Image { get; set; } = "";
}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— one required setting. The Blazor admin ships entirely inside theKlassd.Backofficepackage, so a host that has no.razorfiles of its own must opt in to the Blazor framework assets, or/admin404s on_framework/blazor.web.jsand never goes interactive:<PropertyGroup> <RequiresAspNetWebAssets>true</RequiresAspNetWebAssets> </PropertyGroup>This can't be set by the package itself: the property gates a restore-time framework download, and NuGet restore does not read a referenced package's MSBuild props (it would be circular), so a value Klassd set would arrive too late. It must live in your host project. (If your host already has its own
.razorfiles, the SDK turns this on automatically and you can omit it.)
3. Run and open /admin. The public site reads published content from /api/pages.
See src/Klassd.Sample for a complete runnable host with multiple page/block
types, a custom property editor, media sections and SSO examples.
Media is organized into named sections, each backed by its own blob adapter. Add a section
with .AddMedia(...) after choosing storage, and reference it from a field with
[CmsField(FieldType = "media")] (the admin renders an upload + picker; the field stores the item id):
dotnet add package Klassd.Media.FileSystem # and/or .Media.S3, .Media.GoogleCloudbuilder.Services
.AddKlassd(builder.Configuration)
.UseSqlite(builder.Configuration.GetSection("Sqlite"))
.AddMedia(media =>
{
// Local disk — longest image edge downscaled to 2000px in-browser before upload
media.AddSection("images", s => s
.UseFileSystem(Path.Combine(builder.Environment.ContentRootPath, "media", "images"))
.AllowContentTypes("image/*")
.ResizeImages(2000)
.Breakpoints("default", "mobile", "tablet", "desktop")); // focal-point breakpoints
// Amazon S3 (or any S3-compatible backend via ServiceUrl/ForcePathStyle)
media.AddSection("documents", s => s
.UseS3(o =>
{
o.Bucket = "my-cms-docs";
o.Region = "eu-west-1"; // omit AccessKey/SecretKey to use the default AWS credential chain
})
.AllowContentTypes("application/pdf"));
// Google Cloud Storage
media.AddSection("video", s => s
.UseGoogleCloudStorage(o =>
{
o.Bucket = "my-cms-video";
o.CredentialsPath = "/secrets/gcs.json"; // or CredentialsJson, or ambient ADC
}));
});Each section is independent: mix local disk, S3 and GCS in the same app, set per-section allowed
content types, downscale images on the client with ResizeImages(maxEdgePixels), and declare the
focal-point Breakpoints(...) editors pick from (a single "default" when unset).
Need a backend we don't ship (Azure Blob, an in-house store, …)? A media adapter is just an
IBlobStore(three methods) plus aUseXxxextension. Seeexamples/InMemoryMediaAdapterfor a complete, annotated walkthrough.
Klassd's storage and media backends are swappable extension points — the engine depends only on
interfaces in Klassd.Abstractions, never on a concrete database or cloud SDK. To target a backend
we don't ship, implement the relevant interface and add a UseXxx registration extension. Worked,
compilable examples live in examples/:
| Example | Implements | Extension point |
|---|---|---|
InMemoryMediaAdapter |
IBlobStore |
UseInMemoryBlobs() on a media section |
InMemoryStorageAdapter |
IPageStore, IMediaStore, IDictionaryStore, IUserStore, IPreferencesStore, IUnitOfWork, IStorageInitializer |
UseInMemoryStorage() on the CMS builder |
The headless GET delivery endpoints are anonymous so a public frontend can read published content without credentials:
GET /api/pages,/api/pages/{id},/api/pages/content/{contentId},/api/pages/{id}/translationsGET /api/dictionary/resolved/{locale}GET /api/media/{id}
Everything else — page/media/dictionary management, users, preferences, and the /admin UI — still
requires the admin cookie.
Restrict which browser origins may fetch via JS with config (empty/unset ⇒ any origin):
"Klassd": {
"Cors": { "AllowedOrigins": [ "https://www.example.com", "https://shop.example.com" ] }
}CORS only limits cross-origin browser requests; it is not an authorization boundary for server-side callers. Delivery content is genuinely public — don't put gated content in it.
This is a headless CMS, so the rendering model is the consumer's choice. If your frontend renders server-side (SSR/SSG/BFF), you can require an API key on the delivery GETs — the key stays on your server, never in the browser:
"Klassd": {
"Delivery": { "RequireApiKey": true, "ApiKey": "<long-random-secret>", "ApiKeyHeader": "X-Api-Key" }
}Callers then send X-Api-Key: <secret>; requests without it get 401. Default is off (public).
Do not enable this for a browser/SPA that calls the CMS directly — the key would ship in the client bundle and provide no real protection. For a client-side app that needs gating, put a backend-for-frontend (BFF) in front that holds the key, or use per-user auth.
| Package | Purpose |
|---|---|
Klassd.Abstractions |
Storage adapter interfaces + DB-agnostic POCOs (no deps) |
Klassd.Core |
Content base types, attributes, registries, localization, default property types |
Klassd.Backoffice |
The engine: AddKlassd/UseKlassd, Blazor admin, headless /api |
Klassd.Data.MongoDb / .Data.Postgres / .Data.Sqlite |
Storage adapters |
Klassd.Cache.InMemory / .Cache.Redis |
Read-through page cache adapters |
Klassd.Media.FileSystem / .Media.S3 / .Media.GoogleCloud |
Media blob adapters |
Klassd.Auth.OpenIdConnect |
OIDC/OAuth SSO for the backoffice (SAML via the generic seam) |
The engine package carries no MongoDB/AWS/Google dependency — each adapter keeps its SDK isolated, so you only pull in what you wire up.
Block scheduling is market-local: each locale carries an IANA TimeZone (e.g. Europe/Berlin,
Asia/Dubai) and editors author schedule times as wall-clock time in that market. Times are stored
and compared in UTC, so delivery is correct across markets simultaneously.
Resolving IANA time zones requires the OS time-zone database:
-
Debian/Ubuntu base images (incl. the default
mcr.microsoft.com/dotnet/aspnet) include it — nothing to do. -
Alpine images do not. Add it:
RUN apk add --no-cache tzdata -
Chiseled / distroless: use the
-extraimage variant (ships ICU + tz data), not the bare one.
If a configured locale time zone can't be resolved at startup, the engine logs a warning
(category Klassd.Scheduling) naming the locale and zone, and falls back to UTC for that
market (so scheduling would be offset-wrong). Watch for that warning when deploying to slim images.
Note: this is about the time-zone database, not
InvariantGlobalization— leaving globalization invariant does not remove the tz-data requirement.
dotnet build Klassd.slnx -c ReleaseTests use TUnit on the Microsoft.Testing.Platform. On the .NET 10 SDK,
dotnet test does not work for these — run each project directly:
dotnet run --project tests/Klassd.UnitTests -c Release
dotnet run --project tests/Klassd.IntegrationTests -c Release # needs Docker (Testcontainers); container tests auto-skip without it
dotnet run --project tests/Klassd.UiTests -c Release # needs Playwright browsers (see below)UI tests are Playwright E2E; first run installs the browser:
pwsh tests/Klassd.UiTests/bin/Release/net10.0/playwright.ps1 install chromiumSee SECURITY.md for the vulnerability reporting process and important notes on the public delivery endpoints.
Klassd was built largely with AI assistance — Claude Code (Anthropic's Claude) was used throughout for design, implementation, refactoring and tests, working alongside a human maintainer who reviews and directs the work. The architecture, content model and adapter design were shaped through that collaboration, and most commits are co-authored accordingly.
It's called out here for transparency: read the code with the same scrutiny you'd give any dependency, and please report anything that looks off via the issues or SECURITY.md.
Klassd stands on excellent open-source work. Thank you to the maintainers of:
- daisyUI (MIT) — the component layer the admin UI is built on. Vendored as
Klassd.Backoffice/wwwroot/daisyui.css(the file keeps its license header), so no build step is required. - Tailwind CSS (MIT) — the utility/design-token foundation daisyUI is built on.
- Lucide (ISC) — the page-type / UI icon set (
TypeIcon). - Vue + Vite (MIT) — the SSR frontend and its build.
- Bun (MIT) — the frontend runtime/server.
- Playwright (Apache-2.0) and TUnit (MIT) — the test stack.
- .NET & Blazor (MIT) — the platform Klassd is written on.
MIT © Mark Lonquist








