Making Built-in CMS Categories Queryable in Optimizely Graph (CMS 13 PaaS)
With Optimizely CMS 13 PaaS, Optimizely Graph replaces Search & Navigation as the content query engine. However, if your existing CMS solution relies on the built-in PageData.Category property for content filtering, you may notice that these categories aren't available as queryable fields in Graph.
This is particularly relevant when migrating from CMS 12, where categories may already be used extensively for content listings and search.
I recently investigated this limitation, experimented with different approaches, and eventually built Mardy.Optimizely.Graph.Categories, an open-source NuGet add-on that makes built-in CMS category IDs and names queryable and filterable in Optimizely Graph.
The goal is simple: keep using existing CMS categories without modifying your page models or manually registering computed fields for each content type.
The problem with built-in categories
Optimizely CMS provides PageData.Category, allowing editors to assign categories to pages.
In CMS 13 PaaS, these assignments are still available through the CMS, but they aren't exposed by default in the generated Optimizely Graph schema.
For example, you might want to retrieve all pages assigned to a category named News. While this was possible through Search & Navigation, attempting to filter directly on PageData.Category using the new Graph C# SDK doesn't work as expected because the property is a CategoryList.
This limitation has also been raised in the Optimizely feedback portal, particularly for projects migrating from CMS 12 that already depend on category-based filtering.
Investigating category support in Graph
I started investigating this while following a discussion on Jake Minard's Introducing the Optimizely CMS 13 Graph SDK article.
While inspecting Optimizely.Graph.Cms 13.3.0, I found two interesting components:
-
CategoriesBaseTypeMapper, an internal mapper for category data.
-
ContentMetadataDefinitions.Categories, which defines a queryable category field.
However, the mapper wasn't registered in the default dependency injection configuration, and the category field wasn't included in the generated ContentMetadata Graph schema.
As an experiment, I registered the internal mapper through reflection. After indexing, the category names appeared correctly in the indexed JSON under _metadata.categories.
However, querying or filtering on _metadata.categories through GraphQL still failed because the field wasn't present in the generated schema.
I also tried adding the category field directly to ContentMetadata through the Graph Content API. This made it queryable, but the changes were lost after a full synchronization.
These experiments confirmed that neither approach was suitable for a reusable solution.
Using Graph computed fields
In the same community discussion, Valina Eckley shared a workaround provided by Optimizely Support that uses computed fields and Graph indexing conventions to expose category information.
You can find her implementation in the comments on Jake Minard's CMS 13 Graph SDK article.
The approach uses extension methods to retrieve category information and registers them as indexed fields through Graph conventions.
That gave me a supported direction to build on.
I wanted to make the integration easier to reuse across CMS projects without requiring developers to write the same extension methods, modify their page models, or register conventions individually for each page type.
This led to Mardy.Optimizely.Graph.Categories.
Introducing Mardy.Optimizely.Graph.Categories
The add-on automatically registers two computed fields on eligible concrete page types:
| Field | Description |
|---|---|
| LegacyCategoryIds | IDs of the categories assigned to the page |
| LegacyCategoryNames | Names of the categories assigned to the page |
Both fields use IndexingType.Queryable, making them available for GraphQL filtering.
The implementation uses public CMS and Graph APIs, including GraphConventionOptions, GraphComputedFieldDefinition, and CategoryRepository.
It doesn't require reflection against internal Optimizely types, direct Graph schema modifications, or additional properties on existing page models.
Installation
The package is available on the official Optimizely NuGet feed.
Install version 1.0.0 using:
dotnet add package Mardy.Optimizely.Graph.Categories --version 1.0.0
You can also find the source code, examples, and release notes on GitHub.
Configuration
Register the add-on alongside Optimizely Graph in your application's service configuration:
using Mardy.Optimizely.Graph.Categories;
services.AddContentGraph();
services.AddPageCategoriesToGraph();
That's it!
The add-on discovers eligible concrete page types and registers the computed fields automatically.
After starting the application, run Optimizely Graph Full Synchronization from Settings > Scheduled Jobs.
This updates the Graph schema and indexes the category values for existing pages.
Querying categories with GraphQL
Once synchronization completes, the new fields become available on individual page types.
For example, to retrieve all StandardPage items assigned to the Track category:
{
StandardPage(
where: {
LegacyCategoryNames: {
in: ["Track"]
}
}
) {
total
items {
_metadata {
displayName
}
LegacyCategoryIds
LegacyCategoryNames
}
}
}
You can also filter by category ID:
{
StandardPage(
where: {
LegacyCategoryIds: {
in: [4]
}
}
) {
total
items {
_metadata {
displayName
}
LegacyCategoryIds
LegacyCategoryNames
}
}
}
Both approaches were verified in my CMS 13 Alloy test projects.
Pages assigned to multiple categories are also supported. Filtering by any of the assigned category IDs or names returns the matching page.
One important detail: LegacyCategoryNames uses the category's Name, not its Display Name.
For example, if the Name is Track and the Display Name is Alloy Track, you should filter using Track.
Using the Optimizely Graph C# SDK
The computed fields can also be used through the Optimizely.Graph.Cms.Query SDK, which needs to be installed separately.
For example:
using Mardy.Optimizely.Graph.Categories;
using Optimizely.Graph.Cms.Query;
using Optimizely.Graph.Cms.Query.Abstractions;
using Optimizely.Graph.Cms.Query.Filtering;
var pages = await graphClient
.QueryContent<StandardPage>()
.Where(x => x.LegacyCategoryNames(default!).ContainsAny("Track"))
.IncludeTotal()
.GetAsContentAsync();
The SDK translates ContainsAny("Track") into the corresponding GraphQL in filter.
You can also retrieve the computed field values using a projection:
public class CategoryValues
{
public List<int> LegacyCategoryIds { get; set; } = [];
public List<string> LegacyCategoryNames { get; set; } = [];
}
var result = await graphClient
.QueryContent<StandardPage>()
.Where(x => x.LegacyCategoryIds(default!).ContainsAny(4))
.Fields<CategoryValues>(
x => x.LegacyCategoryIds,
x => x.LegacyCategoryNames)
.GetAsync();
Both filtering and projection were tested against the live Graph schema.
Important considerations
There are a few limitations worth knowing.
Filtering across page types
The computed fields are registered on concrete page types such as StandardPage.
They aren't available on the built-in _Page Graph type, so filtering across all page types through _Page isn't supported.
You can filter individual page types or query multiple types separately.
Category changes require synchronization
Renaming or deleting a category doesn't automatically update its previously indexed values in Graph.
During testing, I found that delta synchronization didn't refresh these values either.
A full synchronization was required to reflect category renames and deletions.
Category names and IDs
Category IDs are specific to each CMS database and may differ between environments.
Category names aren't necessarily globally unique, since categories under different parents can share the same name.
The add-on also doesn't automatically expand category hierarchies. Filtering by a parent category won't match pages assigned only to its child categories.
Page types
The add-on targets concrete page types derived from PageData.
It doesn't add these fields to blocks, media, or content types that exist only in the CMS database without a CLR model.
For additional configuration options and limitations, see the GitHub README.
Compatibility and testing
I tested the add-on against two Optimizely CMS 13 environments:
-
CMS and Graph 13.4.0
-
CMS and Graph 13.5.0
The tests covered filtering by category name and ID, multiple assigned categories, repeated full synchronizations, C# SDK queries, and category rename/delete behavior.
The project also includes 29 unit tests, executed through GitHub Actions against both supported SDK versions.
Version 1.0.0 was validated using the CI-built NuGet package installed in an Alloy CMS 13.5.0 application.
Source code and package
The project is open source under the MIT license.
-
NuGet package: Optimizely NuGet feed (v1.0.0)
Final thoughts
Built-in CMS categories remain useful for many Optimizely projects, particularly when migrating existing category-based search and filtering functionality to CMS 13.
Although these categories aren't currently exposed as queryable fields in the default Graph schema, computed fields provide a practical way to make them available.
Mardy.Optimizely.Graph.Categories builds on that approach by automating the registration and making it reusable across projects without changes to existing page models.
Thanks to Valina Eckley for sharing the Optimizely Support workaround that helped guide this implementation, and to everyone contributing to the community discussion around category support in Graph.
I hope this helps other developers working through similar CMS 13 migrations.
Feedback and contributions are welcome on GitHub.
Comments