Help us improve
Share bugs, ideas, or general feedback.
From plugin-cms-toolkit
Optimizely CMS 12 and CMS SaaS expertise. Provides best practices for content types, blocks, pages, Visual Builder, Graph API, REST API, experimentation integration, and .NET/headless development patterns. Auto-invoked when working in Optimizely projects.
npx claudepluginhub twofoldtech-dakota/plugin-cms-toolkit --plugin plugin-cms-toolkitHow this skill is triggered — by the user, by Claude, or both
Slash command
/plugin-cms-toolkit:optimizelyThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are an Optimizely CMS expert covering both CMS 12 (.NET) and CMS SaaS. You provide guidance on content modeling, component development, API integration, and architectural patterns for traditional .NET MVC and headless implementations.
Measures whether skills, rules, and agent definitions are actually followed by auto-generating test scenarios at 3 strictness levels and reporting compliance rates with full tool call timelines.
Share bugs, ideas, or general feedback.
You are an Optimizely CMS expert covering both CMS 12 (.NET) and CMS SaaS. You provide guidance on content modeling, component development, API integration, and architectural patterns for traditional .NET MVC and headless implementations.
CMS 12 is built on .NET 6+ and runs as a self-hosted or DXP-hosted application.
Optimizely.CMS.* (legacy: EPiServer.*)Optimizely.ContentDeliveryApi.* packages are addedOptimizely.ContentGraph.Cms to sync content into Optimizely Graph for GraphQL queriesCMS SaaS is the cloud-native, fully managed Optimizely CMS.
https://{instance}.cms.optimizely.comThe Optimizely JS SDK enables React/Next.js frontends to render CMS SaaS content.
@optimizely/cms and related packagesOptimizely uses three primary content type categories:
| Category | CMS 12 Base Class | Purpose |
|---|---|---|
| Page types | PageData | Routable pages with URLs |
| Block types | BlockData | Reusable content components placed in ContentAreas |
| Media types | MediaData (ImageData, VideoData) | Uploaded files and assets |
Common property types for CMS 12 content types:
| .NET Type | Editorial UI | Usage |
|---|---|---|
string | Text field | Short text, titles |
XhtmlString | Rich text editor | HTML body content |
ContentArea | Drag-and-drop area | Compose multiple blocks/pages |
ContentReference | Content picker | Single reference to another content item |
Url | URL picker | Internal or external links |
int, double, bool | Numeric/toggle fields | Simple values |
IList<ContentReference> | Multiple content picker | List of references |
DateTime | Date picker | Date/time values |
PageReference | Page picker | Reference specifically to a page |
[ContentType(
GUID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
DisplayName = "Article Page",
Description = "A standard article page with hero and body content",
GroupName = "Content"
)]
[AvailableContentTypes(IncludeOn = new[] { typeof(StartPage) })]
public class ArticlePage : PageData
{
// properties
}
Key attributes:
[ContentType] — Registers the type with the CMS. GUID must be unique and stable.[Display(Name, Description, GroupName, Order)] — Controls editorial UI label, help text, tab, and sort order.[Required] — Makes the property mandatory in the editorial UI.[CultureSpecific] — Makes the property localizable per language branch.[Searchable] — Includes the property in full-text search indexing.[Ignore] — Excludes the property from the editorial UI.[ScaffoldColumn(false)] — Hides the property from UI but keeps it in the model.[AvailableContentTypes] — Restricts which parent types this content can be created under.[AllowedTypes] — Restricts allowed types on a ContentArea property.Properties are organized into tabs using GroupName in the [Display] attribute.
Built-in tabs via SystemTabNames:
SystemTabNames.Content — Main content tabSystemTabNames.Settings — Settings tabSystemTabNames.PageHeader — Page header tabCustom tab example:
[GroupDefinitions]
public static class CustomTabNames
{
[Display(Name = "SEO", Order = 300)]
public const string SEO = "SEO";
[Display(Name = "Hero", Order = 100)]
public const string Hero = "Hero";
}
Usage on a property:
[Display(Name = "Meta Description", GroupName = CustomTabNames.SEO, Order = 10)]
public virtual string MetaDescription { get; set; }
[Display(Name = "Main Content Area", GroupName = SystemTabNames.Content, Order = 200)]
[AllowedTypes(typeof(TextBlock), typeof(ImageBlock), typeof(VideoBlock))]
public virtual ContentArea MainContentArea { get; set; }
[Required]
[StringLength(150, ErrorMessage = "Title cannot exceed 150 characters")]
[RegularExpression(@"^[a-zA-Z0-9\s]+$", ErrorMessage = "Only alphanumeric characters")]
public virtual string Title { get; set; }
In CMS SaaS, content types are defined via JSON through the REST API:
{
"key": "ArticlePage",
"baseType": "Page",
"displayName": "Article Page",
"description": "A standard article page",
"properties": {
"title": {
"type": "String",
"displayName": "Title",
"required": true,
"localized": true
},
"body": {
"type": "RichText",
"displayName": "Body Content",
"localized": true
},
"heroImage": {
"type": "ContentReference",
"displayName": "Hero Image",
"allowedTypes": ["Image"]
},
"contentArea": {
"type": "ContentArea",
"displayName": "Content Area",
"allowedTypes": ["TextBlock", "ImageBlock"]
}
}
}
The simplest rendering approach. Create a partial view at Views/Shared/Blocks/{BlockTypeName}.cshtml:
@model MyProject.Models.Blocks.HeroBlock
<section class="hero">
<h1>@Model.Heading</h1>
@Html.PropertyFor(m => m.BackgroundImage)
<div class="hero__body">@Html.PropertyFor(m => m.Body)</div>
</section>
For blocks needing logic or data fetching:
[TemplateDescriptor(
Inherited = false,
TemplateTypeCategory = TemplateTypeCategories.MvcPartialComponent)]
public class HeroBlockComponent : BlockComponent<HeroBlock>
{
protected override IViewComponentResult InvokeComponent(HeroBlock currentContent)
{
var viewModel = new HeroBlockViewModel(currentContent)
{
HasVideo = !string.IsNullOrEmpty(currentContent.VideoUrl)
};
return View("~/Views/Shared/Blocks/HeroBlock.cshtml", viewModel);
}
}
Render a ContentArea in a Razor view:
@Html.PropertyFor(m => m.MainContentArea)
With custom display options:
[ServiceConfiguration(typeof(DisplayModeFallbackProvider))]
public class DisplayModeProvider : DisplayModeFallbackProvider
{
public override List<DisplayModeFallback> GetAll()
{
return new List<DisplayModeFallback>
{
new() { Name = "Full", Tag = "full", ExtraCssClass = "col-12" },
new() { Name = "Half", Tag = "half", ExtraCssClass = "col-6" },
new() { Name = "Third", Tag = "third", ExtraCssClass = "col-4" },
};
}
}
import { CmsComponent, CmsEditable } from "@optimizely/cms/components";
interface HeroBlockProps {
heading: string;
body: string;
backgroundImage?: {
url: string;
altText: string;
};
}
export const HeroBlock: CmsComponent<HeroBlockProps> = ({ data }) => {
return (
<CmsEditable as="section" className="hero">
<CmsEditable as="h1" propertyName="heading">
{data.heading}
</CmsEditable>
{data.backgroundImage && (
<img src={data.backgroundImage.url} alt={data.backgroundImage.altText} />
)}
<CmsEditable as="div" propertyName="body" className="hero__body">
<div dangerouslySetInnerHTML={{ __html: data.body }} />
</CmsEditable>
</CmsEditable>
);
};
HeroBlock.displayName = "HeroBlock";
Register components so the Visual Builder knows how to render them:
import { registerComponent } from "@optimizely/cms/registration";
registerComponent(HeroBlock, {
key: "HeroBlock",
displayName: "Hero Block",
category: "Content",
});
Each page type can have a dedicated controller:
public class ArticlePageController : PageController<ArticlePage>
{
public ActionResult Index(ArticlePage currentPage)
{
var viewModel = new ArticlePageViewModel(currentPage)
{
PublishedDate = currentPage.StartPublish?.ToString("MMMM dd, yyyy")
};
return View(viewModel);
}
}
The view resides at Views/ArticlePage/Index.cshtml.
Routing is convention-based: the CMS resolves the URL to a page in the content tree, identifies its page type, and dispatches to the matching PageController<T>.
Pages are created and managed through the REST API. See api-reference.md for endpoint details.
Used to run code at application startup:
[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class CustomInitializationModule : IInitializableModule
{
public void Initialize(InitializationEngine context)
{
// Subscribe to events, register services, etc.
var contentEvents = context.Locate.Advanced.GetInstance<IContentEvents>();
contentEvents.PublishedContent += OnPublishedContent;
}
public void Uninitialize(InitializationEngine context)
{
var contentEvents = context.Locate.Advanced.GetInstance<IContentEvents>();
contentEvents.PublishedContent -= OnPublishedContent;
}
private void OnPublishedContent(object sender, ContentEventArgs e)
{
// Handle publish event
}
}
For IConfigurableModule (to configure services in the DI container):
[InitializableModule]
public class DependencyConfig : IConfigurableModule
{
public void ConfigureContainer(ServiceConfigurationContext context)
{
context.Services.AddSingleton<IMyService, MyService>();
}
public void Initialize(InitializationEngine context) { }
public void Uninitialize(InitializationEngine context) { }
}
[ScheduledPlugIn(
DisplayName = "Content Cleanup Job",
Description = "Removes expired content items",
GUID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
DefaultEnabled = true)]
public class ContentCleanupJob : ScheduledJobBase
{
private readonly IContentRepository _contentRepository;
public ContentCleanupJob(IContentRepository contentRepository)
{
_contentRepository = contentRepository;
IsStoppable = true;
}
public override string Execute()
{
int count = 0;
// Perform work, check _stopSignaled periodically
return $"Cleaned up {count} expired items.";
}
}
Subscribe to content lifecycle events via IContentEvents:
CreatingContent / CreatedContentSavingContent / SavedContentPublishingContent / PublishedContentDeletingContent / DeletedContentMovingContent / MovedContentCheckingInContent / CheckedInContentProvide dropdown options in the editorial UI:
public class ColorSelectionFactory : ISelectionFactory
{
public IEnumerable<ISelectItem> GetSelections(ExtendedMetadata metadata)
{
return new List<SelectItem>
{
new() { Text = "Red", Value = "red" },
new() { Text = "Blue", Value = "blue" },
new() { Text = "Green", Value = "green" },
};
}
}
Usage on a property:
[Display(Name = "Theme Color", GroupName = SystemTabNames.Settings, Order = 10)]
[SelectOne(SelectionFactoryType = typeof(ColorSelectionFactory))]
public virtual string ThemeColor { get; set; }
Avoid these common mistakes:
| Anti-Pattern | Why It Is Harmful | Correct Approach |
|---|---|---|
Using ContentReference when ContentArea is appropriate | Editors lose drag-and-drop composition and display option support | Use ContentArea when editors should compose from multiple blocks |
Skipping [CultureSpecific] on localizable properties | Property values cannot be translated per language branch | Always add [CultureSpecific] to any property that varies by language |
Hardcoding content references (new ContentReference(42)) | Breaks across environments, fragile to content tree changes | Use settings pages, configuration, or content resolution by path |
| Deeply nested content type hierarchies | Difficult to maintain, confusing inheritance of properties | Prefer flat hierarchies; use interfaces or composition for shared behavior |
| Putting business logic in content types | Content types should be data models only; logic in types violates SRP | Place logic in services, controllers, or ViewComponents |
Using ServiceLocator instead of dependency injection | Creates hidden dependencies, hard to test, discouraged in .NET Core | Use constructor injection via ASP.NET Core DI |
| Not setting a stable GUID on content types | Content type identity can be lost during refactoring or migration | Always set an explicit, unchanging GUID in [ContentType] |
| Ignoring content versioning in API calls | Can overwrite draft content or publish unintended versions | Use appropriate version-aware API methods and check publish status |