Mardy
Opal Tools CertificationOpal Administrator Certificationworkspace_premium +4
Oct 14, 2026
visibility 642
star star star star star
(1 votes)

Computed Fields in CMS 13 Content Contracts with Optimizely Graph

Content Contracts in Optimizely CMS 13 allow different content types to share a common structure when querying Optimizely Graph.

But what if the shared field doesn't exist as a stored CMS property, and its value needs to be calculated differently depending on the content type?

For example, imagine a search feature that includes articles, events, and images:

  • Articles use Heading as their search title.
  • Events use EventName.
  • Images use ImageTitle, falling back to their name.

We want all three to expose a common SearchTitle field in Graph, without adding another persisted property to each content type.

Here's a working approach tested with Optimizely CMS 13.3.0.

1. Define a Content Contract

First, define a Content Contract with a method that each implementing content type uses to provide its search title.

[ContentType]
public interface ISearchableContent : IContentData
{
    string GetSearchTitle();
}

Each content type implements the same contract but calculates the search title using its own properties. For example, an article page uses Heading, while an event page uses EventName.

public class ArticlePage : PageData, ISearchableContent
{
    public virtual string Heading { get; set; }

    public string GetSearchTitle() =>
        string.IsNullOrWhiteSpace(Heading) ? Name : Heading;
}
public class EventPage : PageData, ISearchableContent
{
    public virtual string EventName { get; set; }

    public string GetSearchTitle() =>
        string.IsNullOrWhiteSpace(EventName) ? Name : EventName;
}

Both content types implement the same contract but calculate SearchTitle differently. The same approach works for media types, such as images, using their own properties.

Make sure the relevant content types implement or are associated with the Content Contract in CMS so they can be queried together in Graph.

This keeps the calculation close to each content type and avoids maintaining a central switch statement.

2. Expose the Computed Field in Graph

Next, create an extension method and register it using Graph indexing conventions.

public static class SearchExtensions
{
    public static string SearchTitle(this ISearchableContent content)
        => content.GetSearchTitle();
}

Register the computed field:

services.AddContentGraph(configureConventions: conventions =>
{
    conventions.ForInstancesOf<ISearchableContent>()
        .IncludeField(
            x => x.SearchTitle(),
            IndexingType.Searchable);
});

The important detail is that SearchTitle is computed during indexing. We don't need to add a persisted SearchTitle property to our CMS models.

The resulting Graph schema exposes the field through ISearchableContent, regardless of the underlying content type.

3. Query It with the Graph C# SDK

The interesting part is that the computed field can also be used in strongly typed SDK queries.

First, define a lightweight result model:

public class SearchResult
{
    public string SearchTitle { get; set; }
}

Then query the Content Contract:

var query = graphClient
    .QueryContent<ISearchableContent>()
    .Where(x => x.SearchTitle().Contains("Optimizely"))
    .Limit(20)
    .IncludeTotal()
    .Fields<SearchResult>(x => x.SearchTitle);

var result = await query.GetAsync();

In our test, the SDK translated the filtering expression into GraphQL:

query ISearchableContentQuery {
  ISearchableContent(
    where: { SearchTitle: { contains: "Optimizely" } }
    limit: 20
  ) {
    items {
      SearchTitle
    }
    total(all: true)
  }
}

The query returned one matching event with SearchTitle equal to Optimizely Developer Webinar.

Here's the interesting detail: SearchTitle() is a C# extension method, not a stored CMS property. Graph indexing conventions expose it as a searchable field, and the Graph C# SDK translates the expression into a server-side GraphQL filter rather than filtering an in-memory collection.

We also verified sorting by the computed field, language-specific values, and recalculation after updating and publishing the underlying content.

Key Takeaway

By combining CMS 13 Content Contracts, Graph indexing conventions, and the Graph C# SDK, we can expose a shared searchable field across different content types while keeping the calculation specific to each type.

No additional persisted CMS properties are required.

Keep computed field calculations lightweight and deterministic, since they run as part of the indexing process.

References

Oct 14, 2026

Comments

Johan Kronberg
Johan Kronberg Oct 14, 2026 06:39 AM

It also works well having a body on the interface method making it a default interface method:

string GetSearchTitle() { return "x"; }

Quite nice to not have to implement an actual method everywhere the interface is added...

Johan Petersson
Johan Petersson Oct 14, 2026 03:28 PM

The conventions should not be needed in this case. Is there a specific reason you define `GetSearchTitle` as a method on the interface/contract? If you define it as a property instead you should not need step 2.

[ContentType]
public interface ISearchableContent : IContentData
{
    string SearchTitle { get; set; }
}

Mardy
Mardy Oct 14, 2026 04:02 PM

Thanks Johan! I actually started with a SearchTitle property on the contract and implementing content types, using a calculated getter to return Heading or Name without persisting an additional CMS property.

Initially, I had [Ignore] applied, so I thought that might explain the null values. I tried your suggestion again without [Ignore] and without the indexing convention, followed by reindexing, but got the same result: SearchTitle appears in the Graph schema but returns null.

That's why I moved to GetSearchTitle() with IncludeField(), which successfully indexes the calculated values.

Is there a recommended way to achieve this using a computed contract property without persisting its value or requiring an indexing convention?

Mardy
Mardy Oct 14, 2026 04:08 PM

Thanks @Johan Kronberg, Good point, that would be useful when multiple content types share the same calculation logic, while still allowing individual types to override it when needed.

error Please login to comment.
Latest blogs
Introducing Optimizely Feature Flagging: Flag Your Content Model

Feature flag Optimizely CMS content properties without code changes: visibility, validation, editors and whole content types, with any flag provider.

Andy Blyth | Nov 3, 2026 |

Introducing Optimizely Content Seeder: Seed Your CMS from Code

Seed Optimizely CMS 12 and 13 with pages, blocks, media and Visual Builder sections from a fluent C# definition, with optional AI-written content.

Andy Blyth | Nov 3, 2026 |

Optimizely DXP Alert: Are You Affected by the October 2026 Node.js Security Vulnerabilities?

On October 27, 2026, Optimizely published a security advisory regarding recently disclosed Node.js vulnerabilities affecting Node.js 22.x, 24.x, an...

Madhu | Nov 2, 2026 |

Build Your First Opal Tool in C#

Optimizely ships a C# SDK for building custom

Adnan Zameer | Nov 2, 2026 |