Jake Minard
Mar 25, 2026
  182
(6 votes)

Introducing the Optimizely CMS 13 Graph SDK

Query Optimizely Graph Without Writing GraphQL. A C# fluent API that lets you migrate from Search & Navigation with familiar patterns.

CMS 13 Optimizely Graph Migration SDK Search & Navigation

TL;DR

  • Search & Navigation is gone in CMS 13. Optimizely Graph is the replacement, and the Optimizely.Graph.Cms.Query SDK lets you query it with familiar C# fluent patterns instead of raw GraphQL.
  • The API mirrors what you know: Where() replaces Filter(), SearchFor() replaces For(), Limit() replaces Take(). Your muscle memory transfers.
  • Three entry points: QueryContent<T>() for CMS content, Query<T>() for any type, Query("TypeName") for dynamic scenarios.
  • New capabilities included: Boolean facets, IAsyncEnumerable streaming, decay/factor scoring, pinned results, named queries, and cursor-based pagination.
  • .ToGraphQL() is your best friend. Call it on any query to inspect the generated GraphQL. This will be invaluable during migration.
  • A complete migration reference table (Section 12) maps every Search & Navigation API to its Graph SDK equivalent.

1. Why This SDK Exists

With CMS 13, Optimizely is replacing Search & Navigation (formerly Episerver Find) with Optimizely Graph as the platform's content query engine. This is not an optional migration, Search & Navigation will not be available in CMS 13.

For many partner developers, this triggers understandable anxiety. Search & Navigation's fluent API has been the go-to for content querying for years. Teams have accumulated deep expertise in its fluent Search<T>().For().Filter() patterns, and the prospect of learning GraphQL from scratch feels like a significant lift on top of everything else in a CMS 13 migration.

That's exactly why the Graph SDK exists.

The Optimizely.Graph.Cms.Query package provides a C# fluent API that compiles your familiar expressions into GraphQL queries behind the scenes. You write C#. The SDK writes GraphQL. Optimizely Graph executes it.

You do not need to learn GraphQL to use Optimizely Graph.

The SDK translates your C# lambda expressions into optimized GraphQL queries automatically. If you've used Search & Navigation, you already know 90% of what you need.

The API intentionally mirrors Search & Navigation patterns where possible: Where() replaces Filter(), SearchFor() replaces For(), Limit() replaces Take(). The muscle memory transfers.


2. Getting Started

DI Registration

Register the Graph content client in your Startup.cs or Program.cs:

// Program.cs or Startup.cs
services.AddGraphContentClient();

Inject the Client

Inject IGraphContentClient into your controllers, services, or view components:

public class SearchController : Controller
{
    private readonly IGraphContentClient _graphClient;

    public SearchController(IGraphContentClient graphClient)
    {
        _graphClient = graphClient;
    }
}

Three Entry Points

The SDK provides three ways to start a query, depending on your needs:

Method Use Case Type Constraint Returns
QueryContent<T>() Strongly-typed CMS content queries T : class, IContentData ISearchableContentQuery<T>
Query<T>() Strongly-typed queries for any class T : class ISearchableQuery<T>
Query("TypeName") Untyped queries by content type name None ISearchableQuery

QueryContent<T>() is the most common choice for CMS content. It constrains T to IContentData, giving you access to CMS-specific features like content resolution via GetAsContentAsync().

Query<T>() works with any class, not just CMS content types. Use this when querying custom types indexed in Graph that don't implement IContentData.

Query("TypeName") creates an untyped query using a string type name. Useful for dynamic scenarios where the content type isn't known at compile time.


3. Your First Query

Let's see the same query expressed three ways. Goal: Find all recipes in the "Dessert" category, returning only Name and Description.

Before - Search & Navigation
var result = searchClient.Search<Recipe>()
    .Filter(x => x.Category.Match("Dessert"))
    .Select(x => new { x.Name, x.Description })
    .GetResult();
After - Graph SDK
var result = await _graphClient
    .Query<Recipe>()
    .Where(x => x.Category, "Dessert")
    .Fields<Recipe>(nameof(Recipe.Name), nameof(Recipe.Description))
    .GetAsync();

The parallels are clear: Filter() becomes Where(), Select() becomes Fields<T>(), and GetResult() becomes GetAsync(). The Graph SDK is async-first, returning Task<IContentResult<T>>.

GetAsContentAsync() - CMS Content Resolution

When querying CMS content types, use GetAsContentAsync() to resolve results directly as IContent items. This returns the same fully-resolved content objects that Search & Navigation's GetResult() provided.

var result = await _graphClient
    .QueryContent<Recipe>()
    .Where(x => x.Category, "Dessert")
    .GetAsContentAsync();

// result implements IEnumerable<IContent>
foreach (var content in result)
{
    // content is IContent, fully resolved from CMS
}
Do not use .Fields() with GetAsContentAsync()

The SDK generates a minimal query fetching only content identifiers (_id, _metadata), then resolves full IContent objects through the CMS content loader. Calling .Fields() with an IContentData projection type will throw InvalidOperationException.

Generated GraphQL

For the GetAsync() query above (with Fields), the SDK generates:

{
  Recipe(where: { category: { eq: "Dessert" } }) {
    items {
      Name
      Description
    }
  }
}
Tip: Use .ToGraphQL()

Call .ToGraphQL() on any query to see the generated GraphQL without executing it. This is invaluable for debugging and learning how your C# maps to GraphQL.

string graphql = _graphClient
    .Query<Recipe>()
    .Where(x => x.Category, "Dessert")
    .ToGraphQL();

Console.WriteLine(graphql);
  • GetAsync() returns raw Graph results as IContentResult<T>. Use with .Fields<TProjection>() to specify which fields to return.
  • GetAsContentAsync() resolves results through the CMS content loader, returning IGetAsContentResult<IContent>. Do not use .Fields() with this method.
  • GetAsContentAsync() is only available on QueryContent<T>() queries.

4. Filtering

Filtering is where you'll spend most of your time. The SDK provides multiple approaches, from simple lambda expressions to a dynamic FilterBuilder API.

Lambda Expressions

The most natural way to filter, standard C# comparison operators inside a Where() lambda:

// Equality
.Where(x => x.Category == "Dessert")

// Not equal
.Where(x => x.Category != "Archived")

// Numeric comparisons
.Where(x => x.Rating > 4)
.Where(x => x.CookingTime < 30)

// Boolean
.Where(x => x.IsPublished)
.Where(x => !x.IsArchived)

// Compound conditions with && and ||
.Where(x => x.Category == "Dessert" && x.Rating > 4 && x.IsPublished)
.Where(x => x.Category == "Appetizer" || x.Category == "Dessert")

Full example with compound conditions:

var result = await _graphClient
    .Query<Recipe>()
    .SearchFor("Cookies")
    .Where(x => x.Category == "Dessert" && x.Rating > 4 && x.IsPublished)
    .Fields<RecipeResult>(x => x.Name, x => x.Description)
    .GetAsync();

Generated GraphQL:

{
  Recipe(
    where: {
      _and: [
        { category: { eq: "Dessert" } }
        { rating: { gt: 4 } }
        { isPublished: { eq: true } }
      ]
      _fulltext: { match: "Cookies" }
    }
  ) {
    items { Name  Description }
  }
}
Multiple .Where() calls

You can chain multiple .Where() calls on the same query. Each call adds an AND condition.

String Filters

Extension methods for string operations that go beyond what lambda expressions can express:

// Exact match
.Where(x => x.Author.Match("Jane Doe"))

// Like -- SQL-style wildcards (% = 0+ chars, _ = single char)
.Where(x => x.Title.Like("%chocolate%"))

// Contains -- substring search
.Where(x => x.Title.Contains("chocolate"))

// StartsWith
.Where(x => x.Title.StartsWith("How to"))

// In -- matches any value in a collection
.Where(x => x.Category.In(new[] { "Dessert", "Appetizer", "Main Course" }))

// Exists -- check if field has/lacks a value
.Where(x => x.Author.Exists())       // has a value
.Where(x => x.Author.Exists(false))  // is null

Collection and Range Filters

Numeric and DateTime properties support range-based filtering:

// Numeric range (inclusive)
.Where(x => x.CookingTime.InRange(15, 45))

// Greater than / Less than
.Where(x => x.Rating.GreaterThan(3))
.Where(x => x.CookingTime.LessThanOrEqual(60))

// DateTime range
.Where(x => x.PublishDate.InRange(startDate, endDate))

// DateTime comparisons
.Where(x => x.PublishDate.GreaterThan(DateTime.UtcNow.AddDays(-30)))

Dynamic FilterBuilder API

For scenarios where filters must be built at runtime. Based on user input, iterating over collections, or applying conditional logic:

var filter = _graphClient.BuildFilter<Recipe>();

if (!string.IsNullOrEmpty(selectedCategory))
{
    filter = filter.And(x => x.Category.Match(selectedCategory));
}

if (minRating.HasValue)
{
    filter = filter.And(x => x.Rating.GreaterThanOrEqual(minRating.Value));
}

var result = await _graphClient
    .QueryContent<Recipe>()
    .Filter(filter)
    .Fields<RecipeResult>(x => x.Name, x => x.Description)
    .GetAsync();

Combining with OR logic:

var filter = _graphClient.BuildFilter<Recipe>()
    .Or(x => x.Category.Match("Dessert"))
    .Or(x => x.Category.Match("Appetizer"))
    .Or(x => x.Category.Match("Snack"));
Key FilterBuilder Details

BuildFilter<T>() is an extension method on IGraphContentClient. Each .And() / .Or() call returns a new FilterBuilder<T> instance (immutable). FilterBuilder<T> has an implicit conversion to GraphFilter, so it can also be passed to .Where(GraphFilter).

Combining Where() and Filter()

var dynamicFilter = _graphClient.BuildFilter<Recipe>()
    .And(x => x.IsPublished.Match(true))
    .And(x => x.Rating.GreaterThan(3));

var result = await _graphClient
    .QueryContent<Recipe>()
    .Where(x => x.Category == "Dessert")  // Lambda expression
    .Filter(dynamicFilter)                  // FilterBuilder
    .Fields<RecipeResult>(x => x.Name, x => x.Description)
    .GetAsync();
Where() vs Filter()

Where() adds filter conditions directly. Filter() accepts a FilterBuilder<T>? specifically, which is designed for the dynamic filter-building pattern. Both can be combined in the same query.

See the complete migration reference (Section 12) for S&N filter equivalents.


5. Full-Text Search

Full-text search follows a chain pattern: SearchFor() opens the search context, then UsingFullText() and UsingField() configure how the search is applied.

Basic Full-Text Search

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .SearchFor("chocolate cake recipe")
    .Fields<ArticleResult>(x => x.Title, x => x.Summary)
    .GetAsync();

SearchFor() returns ISearchFieldContentQuery<T>, which adds three search-specific methods: UsingFullText(), UsingField(), and Track().

UsingFullText

Apply the search term across all searchable fields with optional highlighting and boost:

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .SearchFor("chocolate cake")
    .UsingFullText(highlightTag: "<mark>", boost: 2)
    .Fields<ArticleResult>(x => x.Title, x => x.Summary)
    .GetAsync();

UsingField - Targeted Search with Boost

Target the search to specific fields with per-field boost values:

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .SearchFor("chocolate cake")
    .UsingField(x => x.Title, boost: 3, highlightTag: "<strong>")
    .UsingField(x => x.Summary, boost: 2)
    .UsingField(x => x.Body, boost: 1)
    .Fields<ArticleResult>(x => x.Title, x => x.Summary)
    .GetAsync();

See the complete migration reference (Section 12) for S&N search equivalents.


6. Faceted Search

Facets aggregate your results into buckets (categories, date ranges, numeric ranges, or boolean groups). The SDK provides separate Facet() overloads for each data type.

String Facets

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .Facet(x => x.Category)
    .Fields<ArticleResult>(x => x.Title, x => x.Category)
    .GetAsync();

// With ordering and limits
.Facet(x => x.Category,
       limit: 10,
       orderType: OrderType.COUNT,
       direction: OrderDirection.Descending)

Range Facets (int)

var result = await _graphClient
    .QueryContent<Recipe>()
    .Facet(x => x.CookingTime, ranges: new[]
    {
        new RangeFacetInput<int> { From = 0, To = 15 },
        new RangeFacetInput<int> { From = 15, To = 30 },
        new RangeFacetInput<int> { From = 30, To = 60 },
        new RangeFacetInput<int> { From = 60, To = 120 }
    })
    .Fields<RecipeResult>(x => x.Name, x => x.CookingTime)
    .GetAsync();

Boolean Facets New

Boolean facets are new in the Graph SDK (Search & Navigation did not support them):

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .Facet(x => x.IsFeatured)
    .Fields<ArticleResult>(x => x.Title, x => x.IsFeatured)
    .GetAsync();

Facet Filtering: OR Within, AND Between

The Graph SDK applies facet filters with intuitive boolean logic:

  • Within a single facet: values are combined with OR. Selecting "Dessert" and "Appetizer" in Category returns items in Dessert or Appetizer.
  • Between different facets: filters are combined with AND. Selecting "Dessert" in Category and "Jane Doe" in Author returns items matching both.
var result = await _graphClient
    .QueryContent<Recipe>()
    .Facet(x => x.Category, filters: new[] { "Dessert", "Appetizer" })  // OR
    .Facet(x => x.Author, filters: new[] { "Jane Doe" })              // AND with above
    .GetAsContentAsync();

// Returns: (Category is Dessert OR Appetizer) AND (Author is Jane Doe)
Improvement over S&N

In Search & Navigation, achieving OR-within / AND-between facets required executing multiple search queries and merging results. With the Graph SDK, a single query handles it natively.

Reading Facet Results

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .Facet(x => x.Author)
    .Facet(x => x.Category)
    .Fields<ArticleResult>(x => x.Title, x => x.Author, x => x.Category)
    .GetAsync();

// Read facet values (type-safe)
var authorFacets = result.Facets.GetFacet(x => x.Author);

foreach (var facet in authorFacets)
{
    string name = facet.Name;   // e.g., "Jane Doe"
    int count = facet.Count;    // e.g., 15
}

// Other helpers
bool hasFacet = result.Facets.HasFacet("Author");
var fieldNames = result.Facets.FieldNames; // ["Author", "Category"]

See the complete migration reference (Section 12) for S&N facet equivalents.


7. Sorting, Pagination & Streaming

Sorting

// Sort by property (descending)
var result = await _graphClient
    .QueryContent<ArticlePage>()
    .OrderBy(x => x.PublishDate, OrderDirection.Descending)
    .Fields<ArticleResult>(x => x.Title, x => x.PublishDate)
    .GetAsync();

OrderBy() returns IOrderedContentQuery<T>, which allows chaining additional sort criteria with ThenBy():

// Primary sort by date, secondary sort by title
.OrderBy(x => x.PublishDate, OrderDirection.Descending)
.ThenBy(x => x.Title, OrderDirection.Ascending)

Ranking New

When using full-text search, sort by relevance score using the _ranking pseudo-field with a RankingMode:

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .SearchFor("chocolate")
    .UsingFullText()
    .UsingField(x => x.Title, boost: 10)
    .OrderBy("_ranking", RankingMode.Relevance)
    .ThenBy(x => x.PublishDate, OrderDirection.Descending)
    .Limit(15)
    .GetAsContentAsync();

Pagination

// Skip and Limit
var result = await _graphClient
    .QueryContent<ArticlePage>()
    .OrderBy(x => x.PublishDate, OrderDirection.Descending)
    .Skip(20)
    .Limit(10)
    .IncludeTotal()
    .Fields<ArticleResult>(x => x.Title, x => x.PublishDate)
    .GetAsync();

int totalCount = result.Total; // populated when IncludeTotal() is used

IAsyncEnumerable Streaming New

For large result sets, stream results page-by-page without loading everything into memory:

await foreach (var page in _graphClient
    .QueryContent<ArticlePage>()
    .OrderBy(x => x.PublishDate, OrderDirection.Descending)
    .GetAsyncEnumerable<ArticlePage>(pageSize: 50))
{
    foreach (var item in page.Items)
    {
        // Process each item
    }
}

See the complete migration reference (Section 12) for S&N sorting/pagination equivalents.


8. Projections, Autocomplete & Locale

Field Projection

Control which fields are returned to reduce payload size and improve performance:

// Lambda-based (type-safe)
var result = await _graphClient
    .Query<Recipe>()
    .Fields<RecipeResult>(x => x.Name, x => x.Description)
    .GetAsync();

You can project to any class that has matching properties, as long as it does not implement IContentData:

public class RecipeResult
{
    public string Name { get; set; }
    public string Description { get; set; }
}

Autocomplete

Provide type-ahead suggestions based on indexed field values:

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .Autocomplete(x => x.Title, "choc", limit: 5)
    .Fields<ArticleResult>(x => x.Title)
    .GetAsync();

Locale and Multi-Language

Control which language versions of content are returned:

// Single locale
.SetLocale("en")

// Multiple locales
.SetLocale("en", "sv")

// All locales
.SetLocale(QueryLocale.All)

If SetLocale() is not called, the SDK uses the default locale for the current thread.

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .SetLocale("en", "sv")
    .SearchFor("chocolate")
    .Fields<ArticleResult>(x => x.Title, x => x.Summary)
    .GetAsync();

9. Content Variations, Auth & Caching

Content Variations New

Content Variations are a new concept in Optimizely Graph. Content Variations support scenarios like A/B testing and personalization where the same content exists in multiple variations.

// Include a specific variation plus the original content
.SetVariation(includeOriginal: true, "WinterCampaign")

// Include only the specific variation (exclude original)
.SetVariation(includeOriginal: false, "WinterCampaign")

// Include all variations via options builder
.SetVariation(options =>
{
    options.Include = VariationScope.All;
    options.IncludeOriginal = true;
})

WithDisplayFilters() - Context-Aware Access (Recommended)

WithDisplayFilters() is the recommended approach for page rendering scenarios. It automatically detects the current user from Thread.CurrentPrincipal and adjusts authentication accordingly:

  • No authenticated user: Uses SingleKey mode (public content only)
  • Authenticated user detected: Switches to BasicAuth with AsUser(), making user-restricted content visible
  • Always: Filters to published content only
// Recommended for page rendering
var result = await _graphClient
    .QueryContent<ArticlePage>()
    .WithDisplayFilters()
    .Where(x => x.Category == "News")
    .GetAsContentAsync();

Authentication Quick Reference

Method Auth Mode Restricted Content Deleted/Expired Best For
(default) SingleKey No No Public-facing pages
WithDisplayFilters() Auto-detected Yes (if user detected) No (published only) Page rendering
AsUser(principal) SingleKey Yes (for that user) No User-specific access
WithAuth(options) Configurable Yes (with BasicAuth) Configurable Full control

Caching

The SDK caches Graph responses by default (5-minute absolute expiration). You can disable caching or configure custom options per query:

// Disable caching
.WithoutCache()

// Custom cache options
.WithCacheOptions(options =>
{
    // Configure cache duration, etc.
})

10. Advanced Features

Decay Functions - Time-Based Relevance New

Decay functions adjust document scores based on how far a DateTime field is from an origin value. Documents closer to the origin score higher, which can be useful for boosting recent content.

using static Optimizely.Graph.Cms.Query.Linq.Request.GraphQueryFilter;

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .Where(x => Decay(x.PublishDate, new DecayOptions
    {
        Origin = DateTime.UtcNow,
        Scale = 30,
        Rate = 0.5f
    }))
    .Fields<ArticleResult>(x => x.Title, x => x.PublishDate)
    .GetAsync();

Factor - Numeric Field Scoring New

Factor() uses a numeric field's value to influence relevance scores. Useful for boosting documents with higher popularity, stock, or rating:

using static Optimizely.Graph.Cms.Query.Linq.Request.GraphQueryFilter;

var factorOptions = new FactorOptions(2.0f, FactorModifier.Sqrt);
.Where(x => Factor(x.StockQuantity, factorOptions))

// Combine with other conditions
.Where(x => Factor(x.StockQuantity, factorOptions) && x.InStock)

Pinned Results New

Promote specific content items when search queries match configured phrases (similar to Search & Navigation's "Best Bets"):

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .SearchFor("getting started")
    .WithPinned()
    .Fields<ArticleResult>(x => x.Title, x => x.Summary)
    .GetAsync();

// With explicit phrase and collection
.WithPinned(phrase: "getting started", collectionId: myCollectionId)

Named Queries New

Assign a name to your query for logging, tracing, and Graph analytics:

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .SearchFor("chocolate")
    .WithName("SearchPage_MainQuery")
    .Fields<ArticleResult>(x => x.Title, x => x.Summary)
    .GetAsync();

Inspecting Generated GraphQL

Use .ToGraphQL() at any point in the chain to see the GraphQL that would be sent:

string graphql = _graphClient
    .QueryContent<ArticlePage>()
    .SearchFor("chocolate")
    .Where(x => x.Category == "Dessert")
    .OrderBy(x => x.PublishDate, OrderDirection.Descending)
    .Limit(10)
    .ToGraphQL();

_logger.LogDebug("Generated GraphQL: {Query}", graphql);
Tip

.ToGraphQL() is invaluable during development and debugging. Use it to verify your C# expressions produce the expected GraphQL, especially when building complex filter combinations.


11. Working with Results & Untyped Queries

IContentResult<T>

All queries return IContentResult<T>, which contains:

Property Type Description
Items IEnumerable<T> The result items
Total int Total matching items (when IncludeTotal() is used)
Cursor string Cursor for cursor-based pagination
Facets Facet data Aggregation results from Facet() calls
TrackUrls Track URL data URLs for search tracking
CorrelationId string Correlation ID for diagnostics

GetAsync() Variants

// Standard execution with projected fields
IContentResult<ArticleResult> result = await _graphClient
    .QueryContent<ArticlePage>()
    .Fields<ArticleResult>(x => x.Title, x => x.Author)
    .GetAsync();

// Project to a different type at execution time
IContentResult<JsonElement> result = await _graphClient
    .Query("Recipe")
    .Fields("Name", "Description")
    .GetAsync<JsonElement>();

Streaming with GetAsContentAsyncEnumerable New

For CMS content queries, stream results as fully resolved IContent objects:

await foreach (var content in _graphClient
    .QueryContent<ArticlePage>()
    .GetAsContentAsyncEnumerable<ArticlePage>())
{
    // content is IContent, fully resolved through the CMS content loader
}

Untyped / Dynamic Queries

When the content type isn't known at compile time, use the Query(string) entry point:

var result = await _graphClient
    .Query("Recipe")
    .SearchFor("Chocolate Cake")
    .Where("CookingTime", ComparisonOperator.LessThan, 30)
    .Fields("Name", "Description")
    .GetAsync<JsonElement>();

Generated GraphQL:

{
  Recipe(
    where: {
      cookingTime: { lt: 30 }
      _fulltext: { match: "Chocolate Cake" }
    }
  ) {
    items { Name  Description }
  }
}

12. Complete Migration Reference

This table maps every Search & Navigation API to its Graph SDK equivalent. Use it as a quick reference during migration.

Feature Search & Navigation API Graph SDK API Notes
Entry point client.Search<T>() _graphClient.QueryContent<T>() Graph SDK is injected via DI
Full-text search .For("query") .SearchFor("query")  
All fields search .InAllField() .UsingFullText()  
Field-targeted search .InField(x => x.Title, 2.0) .UsingField(x => x.Title, boost: 2)  
Highlight .WithHighlight(spec) .UsingFullText(highlightTag: "<mark>")  
Filter (equality) .Filter(x => x.Category.Match("News")) .Where(x => x.Category == "News")  
Filter (comparison) .Filter(x => x.Rating.GreaterThan(3)) .Where(x => x.Rating > 3)  
Filter (range) .Filter(x => x.Price.InRange(10, 50)) .Where(x => x.Price.InRange(10, 50))  
Filter (contains) .Filter(x => x.Title.Contains("text")) .Where(x => x.Title.Contains("text"))  
Filter (starts with) .Filter(x => x.Title.Prefix("How")) .Where(x => x.Title.StartsWith("How"))  
Filter (ends with) Not directly supported .Where(x => x.Title.EndsWith("Guide"))  
Filter (in collection) .Filter(x => x.Tag.In(values)) .Where(x => x.Tag.In(values))  
Filter (exists) .Filter(x => x.Title.Exists()) .Where(x => x.Title.Exists())  
Filter (wildcard) .Filter(x => x.Title.MatchWildcard("app*")) .Where(x => x.Title.Like("app%")) Different wildcard syntax
Compound filter (AND) .Filter(x => ...).Filter(x => ...) .Where(x => x.A == "a" && x.B == "b")  
Compound filter (OR) .Filter(x => x.A.Match("a") | ...) .Where(x => x.A == "a" || x.B == "b")  
Dynamic filters Combine Filter objects manually .BuildFilter<T>().And(...).Or(...)  
Synonyms .UsingSynonyms() .Match("value", SynonymSlot.One) Per-filter synonym support
String facets .TermsFacetFor(x => x.Category) .Facet(x => x.Category)  
Date facets .DateHistogramFacetFor(x => x.Date, interval) .Facet(x => x.Date, unit: FacetDateTimeUnit.DAY)  
Numeric range facets .RangeFacetFor(x => x.Price, ranges) .Facet(x => x.Price, ranges: buckets)  
Boolean facets Not available .Facet(x => x.IsFeatured) New in Graph SDK
Sort ascending .OrderBy(x => x.Date) .OrderBy(x => x.Date, OrderDirection.Ascending)  
Sort descending .OrderByDescending(x => x.Date) .OrderBy(x => x.Date, OrderDirection.Descending)  
Secondary sort .ThenBy(x => x.Title) .ThenBy(x => x.Title, OrderDirection.Ascending)  
Sort by relevance Not available .OrderBy("_ranking", RankingMode.Relevance) New in Graph SDK
Pagination (skip) .Skip(20) .Skip(20)  
Pagination (take) .Take(10) .Limit(10)  
Total count Included by default .IncludeTotal() Opt-in for performance
Field projection .Select(x => new { x.Title }) .Fields<T>(x => x.Title)  
Autocomplete Via Statistics API .Autocomplete(x => x.Title, "prefix") Built into query API
Best bets Via Best Bets UI .WithPinned() Simplified API
Track search .Track() .Track()  
Locale .Language(lang) .SetLocale("en")  
Execute (sync) .GetResult() N/A Graph SDK is async-only
Execute (async) .GetResultAsync() .GetAsync()  
Streaming Not available .GetAsyncEnumerable<T>(pageSize) New in Graph SDK
Single result Not available .GetSingleAsync() New in Graph SDK
Auth (display filters) Via display modes .WithDisplayFilters() Auto-detects user context

What's New (Graph SDK Only)

These features are available in the Graph SDK but did not exist in Search & Navigation:

Feature API Description
Content Variations SetVariation() Query A/B test and personalization variants
GraphQL Inspection ToGraphQL() View the generated GraphQL query string
Cursor-Based Pagination Cursor in IContentResult<T> Efficient deep pagination beyond skip/limit
IAsyncEnumerable Streaming GetAsyncEnumerable<T>(pageSize) Stream pages without loading all into memory
Like Filter .Like("app%") SQL-style wildcard patterns with % and _
Boolean Facets .Facet(x => x.IsFeatured) Aggregate results by true/false values
Named Queries .WithName("name") Tag queries for logging and Graph analytics
Cache Control .WithoutCache() Per-query cache configuration
Cross-Type FilterBuilder .And<TSource, TOther>(expr) Combine filters across different content types
Direct IContent Resolution GetAsContentAsyncEnumerable<T>() Resolve Graph results directly to IContent
Decay Functions Where(x => Decay(field, options)) Time-based relevance scoring
Factor Where(x => Factor(field, options)) Numeric field relevance scoring
Pinned Results WithPinned(phrase, collectionId) Promote content for specific search phrases
Opt-In Total Count IncludeTotal() Only calculate total when needed (perf opt)
GetSingleAsync GetSingleAsync() Return exactly one result or throw
ThenBy .ThenBy(x => x.Field, direction) Secondary sort criteria after OrderBy()
Ranking Mode .OrderBy("_ranking", RankingMode.Relevance) Sort by relevance, boost, or index order
WithDisplayFilters .WithDisplayFilters() Context-aware auth that auto-detects user

Looking Ahead

The Graph SDK already covers the vast majority of Search & Navigation query patterns. For most CMS content query scenarios (filtering, full-text search, faceting, sorting, pagination, autocomplete, and search tracking) you can migrate your existing code with straightforward API mapping.

Additional capabilities are being added with each release. The SDK is actively evolving based on partner feedback and real-world migration experiences. If you encounter a pattern from Search & Navigation that doesn't have a direct equivalent yet, the Optimizely team wants to hear about it.

Next Steps

  1. Add the Optimizely.Graph.Cms.Query package to your CMS 13 project.
  2. Register with services.AddGraphContentClient() in your startup.
  3. Inject IGraphContentClient and start querying.
  4. Use .ToGraphQL() to inspect generated queries as you learn.

Resources:

Key tip for your migration

Start with .ToGraphQL(). Write your Graph SDK queries, inspect the generated GraphQL, and compare it to what you expect. This feedback loop will build your confidence quickly and help you verify that your migrated queries produce the correct results.

This guide is based on the Optimizely.Graph.Cms.Query SDK for Optimizely CMS 13. API details reflect the current SDK version and may evolve in future releases.

Mar 25, 2026

Comments

Andreas Ylivainio
Andreas Ylivainio Mar 25, 2026 05:43 AM

Great  in-depth post! Love having all the examples collected on one page. My LLM assistans like it too! 😁

Wojciech Seweryn
Wojciech Seweryn Mar 25, 2026 06:17 AM

Very valuable and comprehensive, thanks! I'll definitely find it useful.

Johan Kronberg
Johan Kronberg Mar 25, 2026 07:36 AM

Great post! Have you seen if this client can just run any GraphQL query, at least re-use the config and set correct headers etc?

Spotted a mistake also, ".. This returns the same fully-resolved content objects that Search & Navigation's GetResult() provided." - the equivalent to this is GetContentResult().

Mikael Ekroth
Mikael Ekroth Mar 25, 2026 08:19 AM

First of all great article! 

A question, is it possible to use this in CMS 12 so it's a smoother transistion when the update to CMS 13 will be relevant? 

Please login to comment.
Latest blogs
AEO/GEO in a Legacy Optimizely CMS Project: A Practical Pattern for Google, ChatGPT, and Claude

A practical Optimizely CMS pattern for AEO/GEO on legacy sites: shared page metadata, Razor-rendered JSON-LD, crawler-aware robots.txt, and Schedul...

Wojciech Seweryn | Mar 23, 2026 |

Integrating Searchspring with Optimizely – Part 1: Architecture & Setup

Integrating Searchspring with Optimizely – Part 1: Architecture & Setup

Wiselin Jaya Jos | Mar 20, 2026 |

CMS 13 Preview 4 — Upgrading from Preview 3

This is the third post in a series where I use the Alloy template as a reference to walk through each CMS 13 preview. The first post covered...

Robert Svallin | Mar 20, 2026

The move to CMS 13: Upgrade Notes for Technical Teams

A technical walkthrough of CMS 13 preview3 and headless work: what is changing, where the risks are, and how an upgrade and what to expect

Hristo Bakalov | Mar 20, 2026 |

Customizing Product Data Sent to Optimizely Product Recommendations in Optimizely Commerce

A practical guide to customizing IEntryAttributeService in Optimizely Commerce so you can override product titles, add custom feed attributes, and...

Wojciech Seweryn | Mar 20, 2026 |