We’ve all seen the traditional approach to SEO structured data: a developer hardcodes a <script type="application/ld+json"> block into a view, manually mapping CMS properties to Schema.org fields. While this works for simple sites, it quickly falls apart in multi-market, headless, or enterprise environments.
The moment you need to localize your organization’s address, or provide different contact points for different regions, hardcoded templates become a bottleneck.
In this post, I’ll walk through a pattern to make Schema.org vocabulary a first-class citizen in Optimizely CMS. By embedding the schema directly into the content model, we empower editors to manage SEO data through the UI while ensuring it’s automatically available via the Content Delivery API.
The Architecture: A 3-Layer Approach
To build this, we follow a three-layer pattern:
The Data Models: POCOs that mirror Schema.org vocabulary.
The CMS Properties: Custom PropertyList<T> types for the Optimizely UI.
The Serialization: Custom converters for the Content Delivery API.
Defining Schema.org-Compliant Models
Instead of generic property names like "City" or "State," we should use the exact Schema.org/Organization vocabulary. This ensures that any developer consuming our API knows exactly what the data represents.
namespace MyProject.Cms.Models.Properties.Schema;
public class OrganizationAddress
{
[Display(Name = "Address Name")]
public string? AddressName { get; set; }
[Required]
[Display(Name = "Street Address")] // schema:streetAddress
public string StreetAddress { get; set; }
[Required]
[Display(Name = "City/Locality")] // schema:addressLocality
public string AddressLocality { get; set; }
[Display(Name = "State/Region")] // schema:addressRegion
public string? AddressRegion { get; set; }
[Display(Name = "Postal Code")] // schema:postalCode
public string? PostalCode { get; set; }
[Required]
[Display(Name = "Country (ISO 3166-1)")] // schema:addressCountry
[StringLength(2)]
public string AddressCountry { get; set; }
}Creating the Custom CMS Property
To allow editors to manage multiple addresses or phone numbers, we use PropertyList<T>. This provides a rich collection editor within the CMS.
[PropertyDefinitionTypePlugIn]
public class OrganizationAddressProperty : PropertyList<OrganizationAddress>
{
}Once registered, you can add this to your Home Page or a Global Settings page
[CultureSpecific]
[Display(
Name = "Physical Addresses",
GroupName = "Schema",
Order = 100)]
[BackingType(typeof(OrganizationAddressProperty))]
[EditorDescriptor(EditorDescriptorType = typeof(CollectionEditorDescriptor<OrganizationAddress>))]
public virtual IList<OrganizationAddress>? OrganizationAddresses { get; set; }Automating Content Delivery API Serialization
If you are running headless or decoupled, the default PropertyList serialization might not be exactly what you want. We can implement IPropertyConverter to ensure the API output is clean and ready for a frontend to wrap in JSON-LD.
The Converter
public class OrganizationAddressPropertyModel : PropertyModel<IEnumerable<OrganizationAddress>, PropertyList<OrganizationAddress>>
{
public OrganizationAddressPropertyModel(PropertyList<OrganizationAddress> type) : base(type)
{
Value = type.List?.Select(x => new OrganizationAddress
{
AddressName = x.AddressName,
StreetAddress = x.StreetAddress,
AddressLocality = x.AddressLocality,
AddressRegion = x.AddressRegion,
PostalCode = x.PostalCode,
AddressCountry = x.AddressCountry
});
}
}Consuming the Data: Type-Safe SEO
What about when you need to actually render this data. Whether you are building a traditional MVC site or a headless SPA, you are no longer concatenating strings or hoping the editor didn't break a JSON block.
Because the data is native to the CMS, a developer can consume the typed API and serialize it with full confidence.
// Content editors manage in the CMS UI
// Developers consume via the typed Content Delivery API
var org = contentDeliveryApi.GetContent<HomePage>(startPage);
var json = JsonSerializer.Serialize(new {
context = "https://schema.org",
type = "Organization",
name = org.OrganizationName,
address = org.OrganizationAddresses.Select(a => new {
type = "PostalAddress",
streetAddress = a.StreetAddress,
addressLocality = a.AddressLocality
})
});Why this beats the traditional approach
In a traditional setup, you'd likely have a generic XhtmlString property where an editor pastes a blob of JSON. That is a recipe for broken search results.
By using this pattern:
Intellisense is your friend: You get compile-time checks on your SEO properties.
Automatic Validation: If a "Street Address" is required by the CMS model, the API will never output an invalid Schema object.
Separation of Concerns: The editor focuses on the values (the address, the phone number), and the developer (or the serialization logic) handles the format (the JSON-LD structure).
Summary
Moving Schema.org data into the content model is a small architectural shift that pays huge dividends in maintainability. It treats SEO data as a core asset rather than a frontend afterthought. By defining these models upfront, you ensure that your organization's metadata is as robust and localized as the rest of your page content.