Developer Guide

Content types

A content type is a C# class. The editor, the schema and the typed model all come from it.

A content type is a class you write. The CMS builds the editor from it, and ContentBinder hands it back to your template strongly typed. There is no type designer, and nothing to click.

// Models/Pages/NewsArticle.cs
[ContentTypeId("mysite.page.newsarticle")]
[ContentTypeDisplay("News Article", "A dated story", "page")]
public sealed class NewsArticle : PageType
{
    [FieldId("newsarticle.heading")]
    [FieldDisplay("Heading", "Content", "Main heading", Order = 10)]
    [Required]
    public string? Heading { get; set; }
}

The three base classes

Folder Base class Renders through
Models/Pages/ PageType /Pages/{ClassName}.cshtml
Models/Blocks/ BlockType /Pages/Shared/Blocks/{ClassName}.cshtml
Models/Media/ MediaType nothing — media is served from /media/…

Ids are not names

[ContentTypeId] and [FieldId] are the stable identity of a type and a field. They are what stored content is keyed by, so renaming a class or a property is safe and changing an id is not: change an id and the CMS sees a field that has gone and a different one that has arrived, which is data loss wearing a refactor.

Pick ids once, namespace them to your site, and leave them alone.

The class name is the wiring

StandardPage is served by /Pages/StandardPage.cshtml, whose model is CmsPageModel<StandardPage>. There is no route to register and no map to keep in sync — URLs come from the page tree, so an author moving a page moves its URL and no code changes.

Override the convention with [RendersAt("/Templates/Articles/NewsArticle")] when you want a template somewhere else.

Field types

One property, one control. The type of the property picks the editor:

Type Control Notes
string single-line text escape it when you render it
TextArea multi-line plain text no markup; height from Rows
HtmlString WYSIWYG render unescaped, via Cms.Html(...)
RadioList one of N Horizontal = true lays them out in a row
Checklist zero or more of N stored as a JSON array, read via .Values
DropDown one of N options can be computed at runtime (see an example)
Counter / Integer whole numbers Integer refuses a decimal point outright
Number decimals and sign
DateTimePicker date and time stored UTC, edited in the author's timezone
MediaReference media picker narrow it with [AllowedTypes(File)]
Link internal, external or email one of the three may point at nothing
ContentSelector any-kind picker narrow it with [AllowedTypes(Page)]
Url a plain URL when a picker is the wrong shape
bool a checkbox the label sits beside the box
ContentZone composed blocks see the content zones guide

List<T> and IList<T> have no control yet. Do not ship one to authors.

Populating a DropDown

A DropDown on its own has no choices. [FieldOptions] supplies them, and it has two shapes.

When the list is part of the design, write it inline. A block's background palette is decided by whoever wrote the CSS, so the options belong next to the field:

[FieldId("mysite.promo.background")]
[FieldDisplay("Background Color", "Content", "The background color of the block", Order = 20, CssClass = "cms-w-50")]
[FieldOptions(
    "",        "(default)",
    "light",   "Light",
    "dark",    "Dark",
    "brand",   "Brand red",
    "accent",  "Accent gold")]
public DropDown? BackgroundColor { get; set; }

Pairs are value, label. Store a token such as dark, not #1a1a1a — the template maps the token to a class, so a rebrand changes the stylesheet and no stored content. The blank first option is how the field says "not set"; without it the first colour silently becomes every block's colour.

When the list is data, name a provider. Here the palette lives in appsettings.json, so it changes without a rebuild:

[FieldOptions(typeof(BackgroundColorOptions))]
public DropDown? BackgroundColor { get; set; }
// Extensions/Fields/BackgroundColorOptions.cs
public sealed class BackgroundColorOptions : IFieldOptionsProvider
{
    public ValueTask<IReadOnlyList<FieldOption>> GetOptionsAsync(
        FieldOptionsContext context, CancellationToken ct = default)
    {
        var config = context.Services.GetRequiredService<IConfiguration>();

        var colors = config.GetSection("Site:BackgroundColors").GetChildren()
            .Select(c => new FieldOption(c["value"] ?? "", c["label"] ?? c["value"] ?? ""))
            .Where(o => o.Value.Length > 0);

        IReadOnlyList<FieldOption> options = [new FieldOption("", "(default)"), .. colors];
        return ValueTask.FromResult(options);
    }
}
"Site": {
  "BackgroundColors": [
    { "value": "light", "label": "Light" },
    { "value": "dark",  "label": "Dark" },
    { "value": "brand", "label": "Brand red" }
  ]
}

Nothing registers the provider — it is constructed on each editor render and reaches services through context.Services, so a database query works as well as configuration. Extensions/Fields/RegionOptions.cs is the same pattern, running in this site.

Either way, the template reads a string. In a block partial (@model PromoBlock):

@{
    var bg = Model.BackgroundColor?.Value;
}
<section class="promo @(string.IsNullOrEmpty(bg) ? null : $"promo--bg-{bg}")">
    …
</section>

Validation gates publishing, not saving

[Required] and [Length] stop a page being published. They do not stop a draft being saved.

That is deliberate: an author part-way through writing is in a normal, valid state, and a CMS that refuses to save it teaches people to work somewhere else and paste at the end.

Containers

A page type with no fields and a [Container] attribute shapes the URL hierarchy without resolving on its own — /learn/composers/puccini needs learn and composers to exist as something, but neither needs to render.

A container returns 404 by design. It organises; it does not render. That is not a gap to fill in later.