When building headless websites with Optimizely CMS 12 using the Content Delivery API, ContentArea properties can be tricky. By default, they return minimal references to nested content—not the rich, expanded content structures modern frontends need.
In this post, I’ll walk through how we solved that by implementing a custom ContentArea converter, giving full control over how content blocks are serialized and delivered to the frontend.
In a headless scenario (especially with Optimizely’s PaaS), the frontend lives separately from the CMS. When fetching content via the Content Delivery API, properties like ContentArea only return shallow data:
"myContentArea": [
{
"contentLink": {
"id": 123,
"workId": 0
}
}
]
That’s not helpful if your frontend is expecting a fully-rendered, block-rich JSON object.
Custom IPropertyConverter
We created a custom property converter that:
- Overrides the default serialization behavior for
PropertyContentArea. - Uses
IContentExpanderto fetch and recursively expand all nested content items. - Strips out server-side details not needed by the frontend (like
RouteSegment,Status, orStartPublish).
1. Custom Converter Implementation
public class CustomContentAreaPropertyConverter : IPropertyConverter
{
public IPropertyModel Convert(PropertyData propertyData, ConverterContext converterContext)
{
if (propertyData is not PropertyContentArea propertyContentArea)
throw new InvalidOperationException();
var model = new ExpandedContentAreaPropertyModel(
propertyContentArea,
converterContext,
ServiceLocator.Current.GetInstance<IContentExpander>());
model.Expand(converterContext.Language);
return model;
}
}
2. Registering the Converter
2. Registering the Converter
We use a custom IPropertyConverterProvider to hook this into Optimizely’s serialization pipeline.
public class CustomPropertyConverterProvider : IPropertyConverterProvider
{
public int SortOrder => 200;
public IPropertyConverter Resolve(PropertyData propertyData)
{
if (propertyData is PropertyContentArea)
{
return new CustomContentAreaPropertyConverter();
}
return null;
}
}
Expanding the ContentArea
3. Expanding the ContentArea
The ExpandedContentAreaPropertyModel inherits from ContentAreaPropertyModel and overrides the ExtractExpandedValue method:
protected override IEnumerable<ContentApiModel> ExtractExpandedValue(CultureInfo language)
{
foreach (var item in Value)
{
var expanded = _contentExpander.Expand(
item.ContentLink,
CreateExpandableContext(item.ContentLink, ConverterContext, language));
if (expanded != null)
{
expanded.StartPublish = null;
expanded.StopPublish = null;
// ...strip unneeded fields
yield return expanded;
}
}
}
This ensures a clean and minimal API response, tailored for frontend consumption.
Example JSON Output (with CustomContentAreaPropertyConverter applied)
{
"name": "Homepage",
"contentLink": {
"id": 1001,
"workId": 0,
"guidValue": "5fd26a9d-6c0b-48c1-bf3e-bdbeae3d53e0",
"providerName": null
},
"language": "en",
"myContentArea": [
{
"name": "Promo Banner",
"contentLink": {
"id": 2001,
"workId": 0
},
"heading": "Welcome to Our Platform",
"subHeading": "Your journey starts here",
"description": "<p>This is a hero banner with a call to action.</p>",
"image": {
"url": "/globalassets/media/promo-banner.jpg",
"name": "promo-banner.jpg",
"mimeType": "image/jpeg"
},
"primaryLink": {
"href": "/get-started",
"text": "Get Started"
},
"typeIdentifier": "promoBannerBlock"
},
{
"name": "Features List",
"contentLink": {
"id": 2002,
"workId": 0
},
"heading": "Our Top Features",
"description": "<ul><li>Fast delivery</li><li>Global reach</li></ul>",
"image": {
"url": "/globalassets/media/features-icon.png",
"name": "features-icon.png",
"mimeType": "image/png"
},
"typeIdentifier": "featureBlock"
},
{
"name": "Video Block",
"contentLink": {
"id": 2003,
"workId": 0
},
"heading": "Watch Our Story",
"description": "<p>Learn more about what drives us.</p>",
"videoUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"thumbnail": {
"url": "/globalassets/media/story-thumbnail.jpg",
"name": "story-thumbnail.jpg",
"mimeType": "image/jpeg"
},
"typeIdentifier": "videoBlock"
}
]
}
Each item in myContentArea is a fully expanded block.
Properties like RouteSegment, StartPublish, and Status were stripped out by your converter.
Works great for SPAs, JAMstack apps (Next.js, Nuxt, etc.), or static generators.