The Optimizely Graph SDK Just Made Your Find Migration a Lot Less Scary

The Optimizely Graph SDK Just Made Your Find Migration a Lot Less Scary
Photo by Markus Spiske / Unsplash

When I wrote about CMS 13 in early March, the Search & Navigation to Graph migration was the item I told partners to respect the most. Not because Graph is bad. We've been building on it at WOW for over a year. But because the old Graph .NET Client required a genuine rethinking of how you write queries, and that rethinking multiplied across every search page, listing component, and faceted navigation on a site added up fast.

The week before CMS 13 went GA, Jake Minard published a walkthrough of the new Optimizely Graph C# SDK that changes the picture significantly. If you're scoping a CMS 13 upgrade and the Find migration is the line item keeping you up at night, read Jake's post first. Then come back here for what it means practically.


What the SDK Actually Does

The Optimizely.Graph.Cms.Query package is a C# fluent API that compiles your expressions into GraphQL. You write C#. The SDK writes GraphQL. Graph executes it.

That's the pitch, anyway. What matters for partners is whether the API surface genuinely maps to what you already know. Having gone through Jake's reference in detail, I think it does. The method names were chosen to feel familiar to anyone who's written Find queries:

  • Where() replaces Filter()
  • SearchFor() replaces For()
  • Limit() replaces Take()
  • Fields<T>() replaces Select()
  • GetAsync() replaces GetResult()
  • Facet() replaces TermsFacetFor()

The entry point changes from searchClient.Search<T>() to injecting IGraphContentClient and calling QueryContent<T>(). The SDK is async-first, so GetResult() becomes GetAsync(). But the shape of a query is close enough that a developer who's spent years writing Find queries should be able to read a Graph SDK query and immediately understand what it's doing.

Here's a concrete comparison. A typical Find query:

var result = searchClient.Search<ArticlePage>()
    .For("chocolate cake")
    .Filter(x => x.Category.Match("Dessert"))
    .Filter(x => x.Rating.GreaterThan(3))
    .Take(10)
    .GetResult();

The SDK equivalent:

var result = await _graphClient
    .QueryContent<ArticlePage>()
    .SearchFor("chocolate cake")
    .Where(x => x.Category == "Dessert" && x.Rating > 3)
    .Limit(10)
    .GetAsContentAsync();

The lambda syntax in Where() is arguably cleaner than Find's chained .Filter() calls. You get compound conditions with standard && and || operators instead of chaining multiple filter methods. And GetAsContentAsync() resolves results through the CMS content loader, returning IContent objects. That was one of the quiet anxieties about moving off Find: would you lose the tight integration with the content loader? You don't.


Where It's Genuinely Better Than Find

The SDK isn't just parity. There are several areas where it improves on what Find offered.

Facet logic is native. In Find, achieving OR-within/AND-between facet filtering required executing multiple queries and merging results. The SDK handles it in a single query. Select "Dessert" and "Appetizer" in a Category facet (OR), combined with "Jane Doe" in an Author facet (AND), and it just works. That's a real improvement for any site with multi-faceted search.

Boolean facets exist. Find never supported them. If you needed to aggregate results by a true/false property, you had to work around it. The SDK supports .Facet(x => x.IsFeatured) directly.

Dynamic filter building is cleaner. Find required you to compose FilterExpression objects manually for conditional filters. The SDK's BuildFilter<T>() with .And() and .Or() chaining is more readable, and the builder is immutable so there's less risk of accidentally mutating a shared filter.

Decay functions and factor scoring are new. You can boost recent content using time-based decay on a DateTime field, or use a numeric field's value (ratings, view counts, stock levels) to influence relevance. Find had no equivalent.

.ToGraphQL() is a migration tool in disguise. Call it on any query to see the generated GraphQL without executing it. During migration, this is how you verify that your translated queries are doing what you think they're doing. Write the SDK query, inspect the GraphQL, compare it to the Find query's behavior. That feedback loop is what makes the migration feel safe rather than speculative.


What It Doesn't Solve

The SDK collapses the hardest-sounding part of the migration: the query translation. But query translation was never the whole job, and in my experience it wasn't even the most time-consuming part. Here's what's still on you.

Assessment and Audit

Before you translate a single query, you need an honest inventory of how Find is actually being used. Search the codebase for IClient injections, .Search<T>() calls, the older SearchClient.Instance static pattern, .FilterFacet(), .TermsFacetFor(), .StatisticsTrack(). Count them. Categorise them by feature. That's your migration surface area, and the SDK doesn't reduce it. It just makes each item faster to translate.

Editorial Migration

Best Bets become Pinned Results. The SDK gives you .WithPinned() to query them, and they're managed through the Graph admin tooling or the community OptiGraphExtensions package rather than Find's UI. There's no automated migration path. Every Best Bet needs to be recreated. If your client has a handful, that's straightforward. If they've built up hundreds across multiple languages, script it via the Graph API and budget for it. On the positive side, Opal now includes tooling for managing pinned results and synonym groups directly, which gives editors a more accessible workflow than the raw API.

Synonyms are supported but managed differently. The OptiGraphExtensions package adds synonym management with language routing and slot support, and Opal can manage synonym groups as well. But the editorial workflow is different from what content teams are used to in Find's admin panel. Set expectations early.

Boosting moves from an editorial concern to a developer concern. Find had a UI for it. Graph handles boosting through the query API with field-level boost parameters and the new decay/factor scoring. For teams where marketing actively manages search weighting, that's a workflow change, not just a technical one.

Search Statistics

This is a gap worth flagging. Find had a built-in Statistics view that showed popular searches, zero-result queries, and click-through rates. Content teams used it to identify content gaps, tune relevance, and justify their Best Bet configurations. It was genuinely useful and many teams relied on it without even thinking of it as a "feature."

Graph doesn't have a direct equivalent. The SDK supports .Track() for feeding search events into Graph, and .WithName() lets you tag queries for analytics purposes, which implies data is being collected on the backend. Opal's product page mentions the ability to "analyze search analytics" as part of its Graph search management tools. But there's no documented editorial-facing dashboard that replaces what Find gave you out of the box. If your client's content team actively uses Find's statistics to inform their search curation, set the expectation that this workflow is changing and may require Opal or Optimizely Analytics to replicate.

The Platform Gotchas

These are specific things we've hit at WOW that the SDK doesn't change:

Draft content in public results. Graph indexes both published and draft content. The SDK's WithDisplayFilters() auto-detects user context and adjusts authentication, which helps. But you need to understand the model. Public-facing queries must use the read-only single key. This is a security concern, not just a relevance issue.

System pages leaking into results. Settings pages, container pages, redirect pages, and other internal content types will appear in Graph results unless you explicitly exclude them. The SDK gives you better filtering tools, but it doesn't filter them out by default.

Field type collisions. If you have properties with the same name on different content types but different data types, Graph can have schema conflicts. The PreventFieldCollision setting helps but requires a full reindex and changes field names in queries.

Language fallback. Graph doesn't automatically fall back to the master language for partially translated content. The SDK's SetLocale() is cleaner syntax than the old approach, but the underlying behavior difference from Find is still there. Multilingual sites need to test this carefully.

Graph does index commerce content. The Optimizely.Graph.Commerce package syncs products, variants, bundles, and packages into Graph, and there's a Graph Search Provider for Commerce Connect that translates Mediachase.Search requests into GraphQL queries. For basic product listing pages and category browsing, it works.

But the limitations are real, and they're the reason we still run Find alongside Graph on commerce builds at WOW. The traditional Mediachase.Commerce.Search indexing pipeline isn't supported, so you're relying on the Graph sync job rather than real-time event-driven commerce indexing. There's no market-level or organization-level filtering in Graph, which is a blocker for B2B scenarios where different customer groups see different pricing, catalogs, or product availability. And the commerce search sophistication that Find provided through custom conventions, weighted boosting per catalog, and real-time inventory-aware results doesn't have a Graph equivalent.

The SDK makes the CMS content query side of a commerce site better. It doesn't change the commerce search architecture. If your site has faceted product navigation, variant filtering, or organisation-specific catalog visibility, you're still in the territory I covered in our post on how we architect search with Graph and Coveo. That architecture hasn't changed.

Personalisation and Visitor Groups

Visitor group personalisation — the CMS evaluating audience criteria at render time and deciding which content area items to show — doesn't work through Graph. If you're building headless (which is where CMS 13 is pushing everyone), this probably isn't news. Visitor groups were already marginal in a decoupled architecture because there's no server-side render step for the CMS to evaluate them during. We haven't relied on them for headless builds in a long time.

The real personalisation story for CMS 13 is Content Variations and Web Experimentation, not visitor groups. Content Variations are a first-class feature now with their own version history, Graph indexing, and the SDK's SetVariation() for querying them. That's a better model for testing and personalising content in a headless context.

For teams still doing traditional MVC rendering who actively use visitor groups, though, this is a migration item. Either accept the loss, move the logic to Web Experimentation, or build custom evaluation in your frontend. It's worth surfacing in discovery.

One more: multi-search (executing multiple typed queries in a single request) is explicitly not supported in the SDK. If your Find implementation runs several Search<T>() calls combined into a single round-trip for a search page with multiple result types, you'll need to restructure those as separate async calls. Not a dealbreaker given modern async patterns, but worth knowing before you hit it.


Revised Scoping

In my earlier migration guide I estimated 2 to 4 weeks of developer time for a CMS-only site with moderate Find usage, and 6 to 10 weeks for commerce with deep integration. Here's how the SDK changes that.

CMS-only, moderate Find usage (a search page, some filtered listings, a few dozen Best Bets): I'd now estimate 1 to 3 weeks. The query translation compresses from maybe half the total effort to something closer to a methodical mapping exercise. The assessment, editorial migration, and testing time stay roughly the same. The SDK shrinks the middle of the project, not the ends.

Commerce with deep Find integration: Still 6 to 10 weeks. The SDK helps with the CMS content search portions but doesn't touch the commerce search architecture decisions that drive most of the complexity in these projects.

For both scenarios, the advice hasn't changed: audit early, scope honestly, and don't discover mid-migration that a feature your client depends on doesn't have an equivalent yet. The SDK just made the query-side discoveries less likely to be painful.


Getting Started

If you're running a CMS 13 upgrade, here's what I'd do with the SDK:

Register it with services.AddGraphContentClient() in your startup. Inject IGraphContentClient into a test controller. Take your most complex Find query and translate it. Call .ToGraphQL() and inspect what comes out. If the generated GraphQL does what you expect, you've just validated that the migration path works for your codebase. The rest is volume, not complexity.

We're scoping our first real CMS 13 upgrade now that GA is out. I'll report back on whether these estimates hold up in practice.