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.