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.
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.
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.
var result = searchClient.Search<Recipe>() .Filter(x => x.Category.Match("Dessert")) .Select(x => new { x.Name, x.Description }) .GetResult();
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 }
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
}
}
}
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 }
}
}
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"));
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() 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)
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);
.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
- Add the Optimizely.Graph.Cms.Query package to your CMS 13 project.
- Register with services.AddGraphContentClient() in your startup.
- Inject IGraphContentClient and start querying.
- Use .ToGraphQL() to inspect generated queries as you learn.
Resources:
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.
Great in-depth post! Love having all the examples collected on one page. My LLM assistans like it too! 😁
Very valuable and comprehensive, thanks! I'll definitely find it useful.
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().
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?