Skip to content
Menu
Search
Browse documentation & this page

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 (see an example)
Checklist zero or more of N stored as a JSON array, read via .Values
List<T> rows the author writes T is string or a number; stored as a JSON array in row order
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

A List<T> takes string, int, long, decimal, double or float. Any other element type is a compile-time error: a list of content is a ContentZone, and a fixed set of choices is a Checklist.

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>

Writing a RadioList

A RadioList needs two files that agree with each other: the content type that declares the field, and the partial that draws it. Here is the whole of both, for a block that lets the author pick which side an image sits on.

// Models/Blocks/ImageSideBlock.cs
[ContentTypeId("mysite.block.imageside")]
[ContentTypeDisplay("Image Side", "An image beside text", "block")]
public sealed class ImageSideBlock : BlockType
{
    [FieldId("mysite.imageside.align")]
    [FieldDisplay("Align", "Settings", "Which side the image sits on", Order = 10, Horizontal = true)]
    [FieldOptions(
        "",       "(not set)",
        "left",   "Left",
        "right",  "Right",
        "top",    "Top",
        "bottom", "Bottom")]
    public RadioList? Align { get; set; }
}

Horizontal = true is a [FieldDisplay] property, not something you set on the field type. The field type decides that this is a group of radio buttons; [FieldDisplay] decides how they are laid out, and the default is a stacked column. Four short labels fit a row, so this field asks for one.

Pairs are value, label, the same as a DropDown. The five lines above are five options, not ten: [FieldOptions("left", "right")] is one option whose value is left and whose label is right — an easy mistake to make, and one that looks like a working field until you read the labels. The blank first option is how the field says "not set" without inventing a sentinel value like none.

What is stored is the option value, left, never the label Left. Labels are author-facing text that may be reworded or translated; values are what your template branches on, so they should read like code and stay put.

The class name is the partial name. A block class ImageSideBlock is drawn by Blocks/ImageSideBlock.cshtml, resolved from /Pages/Shared/, so the two names have to agree character for character. Do not call the class plain RadioList after the field type it uses — beyond colliding with Cendia.FieldTypes.RadioList in the same file, which will not compile, the type's name is the only wiring there is and it should name the block, not its widget.

@* Pages/Shared/Blocks/ImageSideBlock.cshtml *@
@model ImageSideBlock
@{
    var align = Model?.Align?.Value;
    var alignClass = align switch
    {
        "left" => "design-block-imageside--left",
        "right" => "design-block-imageside--right",
        "top" => "design-block-imageside--top",
        "bottom" => "design-block-imageside--bottom",
        _ => null,
    };
}
<div class="design-block-imageside @alignClass">
    …
</div>

The _ => null arm is the point of the switch. A blank value renders the base class and nothing else, letting the stylesheet's own default stand; picking one of the four as a fallback would make "not set" indistinguishable from a deliberate left and leave the author no way back.

Validation gates publishing, not saving

[Required], [Length] and [Pattern] 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.

[FieldId("product.code")]
[FieldDisplay("Product code", "Content", Order = 20)]
[Pattern("[A-Z]{2}-[0-9]{4}", Message = "Use a code like AB-1234.")]
public string? Code { get; set; }

A pattern matches the whole value, the way the HTML pattern attribute does, so you do not need ^ and $. It is only checked when the field has a value; add [Required] as well to require one.

What an administrator can change without a deploy

Settings > Content Types has an Edit link on every field your code declares. From there an administrator can change how the field is presented — its label, description, help text, tab, order and width — and how it is checked: make it required or optional, and set its length limits and pattern. Each change is flagged as an override, and Revert to Default puts your declaration back.

What they cannot change is anything stored content depends on: whether the field exists, its [FieldId], its type and [Indexed]. Those stay in your class. So a field relabelled "Headline" in the CMS is still Heading in your template, and if a support conversation turns up a label you do not recognise, the Content Types page is where to look.

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.

Not to be confused with [ContainerOnly], which is for block types and marks an item that belongs inside a container block. See Blocks that hold blocks in the templates guide.