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.