Take the community feedback survey now.

Michał Mitas
Nov 3, 2025
  60
(0 votes)

Going Headless: 3 Ways to Store Custom Data in Optimizely Graph

Welcome to another installment of my Going Headless series. Previously, we covered:

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:

  1. Using the Conventions API
  2. Creating a custom IContentApiModelProperty implementation
  3. 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

Nov 03, 2025

Comments

Please login to comment.
Latest blogs
A day in the life of an Optimizely OMVP - What's New in Optimizely CMS: A Comprehensive Recap of 2025 Updates

Hello and welcome to another instalment of a day in the life of an Optimizely OMVP. On the back of the presentation I gave in the October 2025 happ...

Graham Carr | Nov 3, 2025

Optimizely CMS Mixed Auth - Okta + ASP.NET Identity

Configuring mixed authentication and authorization in Optimizely CMS using Okta and ASP.NET Identity.

Damian Smutek | Oct 27, 2025 |

Optimizely: Multi-Step Form Creation Through Submission

I have been exploring Optimizely Forms recently and created a multi-step Customer Support Request Form with File Upload Functionality.  Let’s get...

Madhu | Oct 25, 2025 |