Going Headless: 3 Ways to Store Custom Data in Optimizely Graph
Welcome to another installment of my Going Headless series. Previously, we covered:
- Going Headless: Making the Right Architectural Choices
- Going Headless: On-Page Editing with Optimizely Graph and Next.js
- Going Headless: Optimizely Graph vs Content Delivery API
So far, we've talked about architectural decisions, on-page editing, and how Optimizely Graph compares to other APIs.
Today, we'll take the next step and look at how to store and expose your custom data in Optimizely Graph — data that doesn't necessarily come directly from CMS properties.
This article walks through three different approaches:
- Using the Conventions API
- Creating a custom IContentApiModelProperty implementation
- Using the Graph Source SDK
Each method offers a different level of control, flexibility, and effort. Let's dive in.
1. Using the Conventions API
The first and most straightforward method for influencing how your data is indexed in Optimizely Graph is through the Conventions API. This API allows developers to modify or extend how CMS content is represented when it's sent to the indexing pipeline. In other words, before your content gets published to Graph, the Conventions API gives you a hook to reshape or enrich the data.
When to use it
If you only need to:
- Exclude specific properties or content types,
- Include some custom fields,
- Add some lightweight metadata derived from existing content properties
…then the Conventions API is your best friend.
Example:
Let's say you have a LandingPage and InternalSettingsPage content types:
// InternalSettingsPage.cs
[ContentType(DisplayName = "Internal Settings Page")]
public class InternalSettingsPage : SitePageData
{
[Display(
Name = "Some Internal Setting",
Description = "A confidential value / setting that should not be published in Graph")]
public virtual string? SomeInternalSetting { get; set; }
}
// LandingPage.cs
using EPiServer.Core;
using EPiServer.DataAnnotations;
using System.ComponentModel.DataAnnotations;
[ContentType(DisplayName = "Landing Page")]
public class LandingPage : SitePageData
{
[Display(
Name = "Secret Property",
Description = "A confidential value not published in Graph")]
public virtual string? SecretProperty { get; set; }
[Display(
Name = "Title Prefix",
Description = "Prefix for the page title")]
public virtual string? TitlePrefix { get; set; }
[Display(
Name = "Title",
Description = "The main page title")]
public virtual string? Title { get; set; }
public virtual string PageTitle()
{
return TitlePrefix + " " + Title;
}
}
For these content types you want to:
- Exclude InternalSettingsPage content type from being indexed to Graph
- Exclude the SecretProperty to prevent it from leaking into Graph, and
- Include custom PageTitle property (being a mix of TitlePrefix and Title properties) in the Graph index
You can do it with a simple convention registration:
// GraphConventions.cs
using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.ServiceLocation;
using Optimizely.ContentGraph.Cms.NetCore.ConventionsApi;
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class GraphConventions : IInitializableModule
{
public void Initialize(InitializationEngine context)
{
var conventionRepository = context.Locate.Advanced.GetInstance<ConventionRepository>();
conventionRepository.ExcludeContentType<InternalSettingsPage>();
conventionRepository.ForInstancesOf<LandingPage>()
.ExcludeField(x => x.SecretProperty);
conventionRepository.ForInstancesOf<LandingPage>()
.IncludeField(x => x.PageTitle());
}
public void Uninitialize(InitializationEngine context) { }
}
Once this convention is registered, Optimizely will skip excluded content types and apply defined adjustments whenever a LandingPage is serialized for indexing in Graph.
2. Creating a Custom IContentApiModelProperty
Sometimes conventions alone aren't enough.
Maybe you want to inject dynamic data that doesn't exist in CMS at all — data coming from another service, or a calculated value that depends on runtime conditions.
That's when IContentApiModelProperty comes into play.
What it does
IContentApiModelProperty allows you to define a new property that will be available for all content items (or selected ones) when indexed in Optimizely Graph.
You can think of it as a plug-in point for adding custom, computed, or external data to the Graph model.
Example: Adding a custom property
Here's a simple implementation:
using Optimizely.ContentGraph.Cms.Core.ContentApiModelProperties;
public sealed class CustomApiModelProperty : IContentApiModelProperty
{
private readonly IContentLoader _contentLoader;
public CustomApiModelProperty(IContentLoader contentLoader)
{
_contentLoader = contentLoader;
}
public string Name => "CustomPropertyName";
public object GetValue(ContentApiModel contentApiModel)
{
// Example: Load the content and derive a value dynamically
if (_contentLoader.TryGet(
new ContentReference(contentApiModel.ContentLink.Id ?? 0),
out SitePageData page))
{
return $"calculated-value-for-{page.Name}";
}
return null;
}
}
When this property is registered, every content item sent to Optimizely Graph will include an additional field called CustomPropertyName, with whatever value you return from GetValue().
For example, the Graph output might look like this:
{
"name": "About Us",
"customPropertyName": "calculated-value-for-About Us"
}
Practical uses
- Injecting content from external APIs (for example, pulling stock data for product pages)
- Calculating derived values (e.g., reading time, rating averages)
- Adding environment or contextual metadata
3. Using the Graph Source SDK
The last approach is the most flexible — and the most decoupled from CMS.
If you have data completely external to the CMS (e.g., from a CRM, ERP, or a custom database), you can use the Optimizely Graph Source SDK to push it directly into Graph.
The SDK gives you full control over what and how you publish to Optimizely Graph, allowing you to create custom schemas and populate data manually.
When to use it
Use the SDK when:
- You need to index non-CMS data (e.g., product catalogs, events, user profiles)
- You want to synchronize third-party data sources with Graph
- You're building a headless architecture where CMS is just one of multiple content sources
Example: Pushing external data
Here's a simplified example based on the Graph Source SDK on GitHub:
using Optimizely.Graph.Source.Sdk;
using Optimizely.Graph.Source.Sdk.SourceConfiguration;
public class ExternalProduct
{
public string? Id { get; set; }
public string? Name { get; set; }
public double Price { get; set; }
}
// Initialize the GraphSourceClient by calling the Create method
var source = "custom-source";
var appKey = "your-app-key";
var secret = "your-secret";
// Initialize the GraphSourceClient by calling the Create method
var client = GraphSourceClient.Create(new Uri("https://cg.optimizely.com"), source, appKey, secret);
// Add a language preference
client.AddLanguage("en");
// Configure content type for ExternalProduct
client.ConfigureContentType<ExternalProduct>()
.Field(x => x.Id, IndexingType.Searchable)
.Field(x => x.Name, IndexingType.Searchable)
.Field(x => x.Price, IndexingType.Queryable);
// Save content types to Optimizely Graph
client.SaveTypesAsync();
// Instantiate and assign values for ExternalProduct
var product = new ExternalProduct
{
Id = "SKU-001",
Name = "Custom Running Shoes",
Price = 129.99,
};
// Use the client to sync the product
client.SaveContentAsync(generateId: (x) => x.Id, "en", product);
This code defines a custom schema (ExternalProduct) and publishes it to Optimizely Graph.
Once indexed, you can query your data directly through GraphQL — just like CMS content.
Important note about Optimizely Graph sources:
In the code above, we specified the source as custom-source, but you may choose any name to logically separate your data sources. Each source acts as its own container in Optimizely Graph.
By default, CMS content is stored in the default source. If you were to use default as the source when pushing custom types, you risk overwriting your entire Graph schema for that source. This is because calling client.SaveTypesAsync() replaces all content types in the target source with only those specified by ConfigureContentType.
To avoid disrupting your CMS data and schema, always register external data under a separate source name (such as custom-source). This keeps your external types isolated and safe from unintended changes.
Example query
query CustomQuery {
ExternalProduct {
items {
Id
Name
Price
}
}
}
And you'll get:
{
"data": {
"ExternalProduct": {
"items": [
{
"Id": "SKU-001",
"Name": "Custom Running Shoes",
"Price": 129.99
}
]
}
},
"extensions": {
"correlationId": "998b0a360df95906",
"cost": 23,
"costSummary": [
"ExternalProduct(23) = limit(20) + 3*fields(1)"
]
}
}
Key takeaway
Use the Graph Source SDK when:
- Your data lives outside CMS,
- You want total control over schemas,
- You're integrating Graph as part of a larger headless ecosystem.
It's the most advanced option — but it also opens up the most possibilities.
Wrapping Up
In a headless setup, Optimizely Graph becomes much more than a content delivery layer.
It can act as a centralized data hub — combining CMS content, computed properties, and external datasets under one unified GraphQL API.
To recap:
| Approach | Best for | Level of Effort | Notes |
|---|---|---|---|
| Conventions API | Shaping existing CMS data | 🟢 Low | Ideal for renaming, excluding, or slightly enriching data |
| IContentApiModelProperty | Injecting dynamic or computed data | 🟠 Medium | Great for adding calculated or external fields |
| Graph Source SDK | Indexing non-CMS data sources | 🔴 High | Full control over schema and indexing |
Choosing the right approach depends on your architectural goals — and how “headless” you really want to go.
In most projects, a combination works best:
- Use Conventions for quick tweaks,
- Add custom properties for dynamic logic, and
- Extend with the SDK when you need full control.
That's it for this installment of Going Headless!
In the next part, we'll look at how to query your combined datasets efficiently — and how to design your schema for real-world performance.
Further Reading
- Optimizely Graph Documentation
- Conventions API Reference
- Graph Source SDK on GitHub
- Going Headless: Making the Right Architectural Choices
- Going Headless: Optimizely Graph vs Content Delivery API
Comments