Optimizely migration from CMS 12 to CMS 13
Upgrading from Optimizely CMS 12 to CMS 13 alongside moving the runtime from .NET 8.0 to .NET 10.0 is far more than a routine version upgrade. It is an opportunity to rethink how content is structured, queried, and delivered across modern digital experiences.
In this article we treat one search-heavy CMS 12 solution as a real-world worked example: a long-lived codebase, mixed-era NuGet references, extensive Search & Navigation (Find) usage for listings and site search, and editorial features that depended on familiar CMS 12 APIs. The upgrade branch had to compile on .NET 10, run on CMS 13, and remove Find in favor of Optimizely Graph (Content Graph) while preserving UX parity for full search, listing pages, and prompt-style autocomplete.
We share the technical decisions, challenges, and improvements that showed up in an actual old-vs-new diff across services, controllers, initialization, and UI metadata not a theoretical checklist. A key outcome was a single graph-first query pipeline for search, listings, and prompts, instead of three slightly different Find call paths that had diverged over years.
This guide is aimed at developers and architects who want a strategic, diff-grounded picture of what “CMS 12 → 13 + .NET 10 + Find → Graph” looks like in practice, alongside Optimizely’s official upgrade sequence.
Official baseline:
How to read this as a real-world migration story
The following is a structured case study derived from a representative upgrade branch. It is not a substitute for reading Optimizely’s upgrade doc end to end; it answers the question: “What actually changed in code when a typical Find-heavy site moved?”
Phases that matched how the work unfolded
-
Audit and inventory - NuGet graph, Find usage map (full search vs listing vs autocomplete), ServiceLocator and InitializeModule hotspots, third-party add-ons.
-
Framework and packages -TargetFramework → net10.0, bump Optimizely packages, fix restore errors (NU1202 / NU1608), restore transitive dependencies after removing Find (for example Newtonsoft.Json if still required).
-
Compile-time modernization -DI, ContentReference, routing helpers, SaveAction, metadata extenders, scheduled jobs, shell controllers.
-
Find → Graph - Introduce shared query builder; migrate entry points one by one; compare totals and ordering; add explicit paging and cache options.
-
Stabilization - Admin URL and host changes, applications model verification, content area rendering regression passes, production-like content volume tests.
What the official upgrade guide adds (planning reality)
These points from Optimizely’s upgrade article directly affected the same kind of project:
-
.NET 10+ is mandatory for CMS 13; expect no build until packages match the new TFM.
-
Resolve [Obsolete] on CMS 12 before the jump; they become hard errors in CMS 13.
-
Graph schema can break between CMS 12 and 13 for teams already on Graph; plan frontends and project migration on DXP if applicable (Graph comparison, project migration).
-
Dynamic properties, mirroring, legacy plugin system are removed - search the codebase for EPiServer.Core.DynamicProperty*, EPiServer.PlugIn, mirroring transfer types.
-
DAM asset migration may need to wait for dedicated CMS 13 tools; other upgrade work can proceed in parallel per current docs.
Why move to CMS 13
-
Better long-term maintainability - CMS 13 encourages dependency injection, modern APIs, and strongly typed query patterns. That reduces glue code and makes search behavior easier to reason about.
-
Unified query composition - A graph-first approach supports one pipeline for full search, listing search, autocomplete / prompt search, and filter / facet generation.
-
More predictable filtering and sorting - One composed query flow keeps global search, listing pages, and prompts from drifting apart.
-
Better scalability options - Explicit paging and bounded retrieval make performance characteristics easier to reason about as content volume grows.
-
Future-ready platform alignment - Aligns with Optimizely’s current direction and reduces later upgrade risk.
Why this migration matters and what to target
Why migration is needed
-
Legacy search code paths grow complex over time.
-
Prompt search and full search drift in behavior.
-
Filter and facet parity issues are painful to debug.
-
Large content trees need stable subtree scoping and predictable listing queries.
Migration goals for engineering teams
Define success before rewriting queries:
- Behavior parity – Ensure the existing search UX does not regress
- Single pipeline – Use one composable query model for search, listing, and prompt
- Stable scoping – Maintain reliable subtree scoping for navigation-heavy structures
- Performance safety – Apply explicit paging and bounded query windows
- Extensibility – Enable new filters and sorting within a shared composition layer
Old vs new: concrete differences from the migration diff
Below is a practical comparison taken from the most important changes applied across the codebase in this example upgrade. Together they move the solution from a page-and-service-locator style to a content-first, DI-first, graph-based CMS 13 architecture.
1. NuGet package modernization
-
Legacy and mixed-era references were cleaned up.
-
ASP.NET Core friendly CMS 13 packages were added or updated.
-
Supporting packages were reviewed and partially aligned.
Already upgraded or aligned on the migration branch:
- EPPlus upgraded to version 8.5.3
- AdaptiveImages upgraded to 2.0.15.2-preview
- Jhoose.Security.Admin upgraded to Jhoose.Security.Admin13 v3.1.0.10
- Stott.Optimizely.RobotsHandler upgraded to 7.0.0
Pending compatibility validation (treat as follow-up before production):
-
Geta.NotFoundHandler.*
-
Geta.Optimizely.*
Optimizely’s upgrade guide also calls out packages such as Geta.NotFoundHandler.Optimizely, Geta.Optimizely.Sitemaps, Geta.Optimizely.GenericLinks, Geta.Optimizely.ContentTypeIcons, and Advanced.CMS.AdvancedReviews as commonly problematic see Optimizely and third-party package breaking changes.
2. Search engine migration (Find to Content Graph)
EpiServer.Find is not supported in CMS 13. In this migration:
-
FindClient.Instance.Search<T>() (and similar) pipelines were removed.
-
New queries start from _graphContentClient.QueryContent<T>().
-
Composition uses .AsCurrentUser(), .SetLocale(...), .WithDisplayFilters(), .WithCacheOptions(...), and async execution (GetContentAsync / GetAsContentAsync).
-
Listing-style helpers that used Find with Ancestors() and StaticallyCacheFor(...) became async: resolve a normalized site path from the start or root page URL, filter with RelativePath.StartsWith(rootPath), Limit(...), then await ...GetAsContentAsync().ConfigureAwait(false) and map or cast; in-memory ordering (for example SortIndex then name) followed where Graph ordering did not match legacy search.
Further reading:
3. Routing and context helper migration
-
IPageRouteHelper / PageRouteHelper usage moved toward IContentRouteHelper / ContentRouteHelper.
-
Note: IPageRouteHelper still exists in CMS 13 but is deprecated (planned removal in a future major); plan migration off it when warnings appear.
-
Page-centric patterns became content-centric; cast to PageData only when required.
4. Type migration for references
-
PageReference members and parameters became ContentReference where appropriate; .PageLink → .ContentLink where applicable.
-
Root and start-page checks use ContentReference.RootPage and ContentReference.StartPage patterns aligned with CMS 13’s application model (replacing older ISiteDefinitionResolver / SiteDefinition.Current-style access where the diff touched them). See the official upgrade article for IApplicationResolver examples.
5. Dependency injection modernization
-
Many ServiceLocator.Current.GetInstance<T>() calls were removed in favor of constructor injection and GetRequiredService<T>() in services, controllers, and initialization modules.
-
Remaining ServiceLocator in lazy static fields moved from GetInstance<T>() to GetRequiredService<T>() so missing registrations fail fast instead of returning null.
-
In InitializationEngine modules, context.Locate.Advanced.GetInstance<T>() became context.Services.GetRequiredService<T>() where supported; obsolete resolves (for example IContentTypeRepository used only for legacy setup) were dropped where safe.
-
Where IContentTypeRepository remains: in CMS 13 it is no longer generic use IContentTypeRepository without a type argument.
6. Indexing metadata updates
-
Legacy [Searchable(false)] attributes became [IndexingType(IndexingType.Disabled)] where indexing should be suppressed under the new model.
7. Content list and filter API updates
-
In this codebase, some paths had already abstracted FilteredItems-style access behind helpers. Under CMS 13, ContentArea.FilteredItems is removed; the supported replacement is ContentArea.Items. Retest every content area and block rendering path filtering semantics are not guaranteed to be identical to legacy FilteredItems.
8. Save behavior and metadata extenders
-
Save calls moved from SaveAction.Non to SaveAction.Default, preserving compatibility flags where required (for example SaveAction.Default | SaveAction.ForceNewVersion with AccessLevel.NoAccess unchanged).
-
Some block UIDescriptor constructors dropped explicit SortKey = new SortColumn { ColumnName = "typeIdentifier", SortDescending = true } where the UI no longer depended on that default sort.
-
IMetadataExtender implementations became async: ModifyMetadata → ModifyMetadataAsync, with await metadata.GetPropertiesAsync(cancellationToken) (and nested collections the same way) instead of synchronous Properties casts. Some classes dropped legacy [ServiceConfiguration(IncludeServiceAccessor = false)] and adopted current infrastructure attribute namespaces.
9. Editor descriptor and UI metadata cleanup
-
Descriptor metadata was simplified and modernized.
-
Command icon class values were updated to current shared block icon conventions.
10. Scheduled job attribute alignment
-
Scheduled job annotations were updated to CMS 13 compatible usage.
11. Site / application resolver abstraction
-
IApplicationResolver for site URL and application name resolution uses routed application context instead of legacy global patterns; verify Settings → Applications after first run on CMS 13 (Application framework).
12. Startup registration alignment
Use using EPiServer.DependencyInjection; for AddCms() and related extension methods.
services
.AddCms()
.AddContentGraph()
.AddContentManager();
Order matters: register AddContentGraph() before AddContentManager(). Enable Graph in the PaaS portal before adding Graph packages. Typical package references on the branch included:
<PackageReference Include="Optimizely.Graph.Cms" Version="13.0.2" />
<PackageReference Include="Optimizely.Graph.Cms.Query" Version="13.0.2" />
<PackageReference Include="EPiServer.Cms.UI.ContentManager" Version="13.0.2" />
CMS 13 assembly scanning means every referenced package needs a matching Add*() registration or must be removed. SQL Server compatibility level 140+ is required (see official guide for UpdateDatabaseCompatibilityLevel).
- NU1202 on .NET 10.0 – Upgrade or remove incompatible third-party packages
- NU1608 – Align all EPiServer.* / Optimizely.* packages to the same CMS 13 version
- Missing types after removing Find – Re-add explicit dependencies (e.g., Newtonsoft.Json) or migrate to System.Text.Json
13. Model transforms and content store projections
-
Some transforms moved from ContentDataStoreModelTransform to TransformBase<ContentDataStoreModel>.
-
Synchronous TransformInstance overrides became Task TransformInstanceAsync(..., CancellationToken cancellationToken = default) with await base.TransformInstanceAsync(...) where applicable.
14. Shell controllers and BCL-style validation
-
Shell or admin controllers that resolved services from ServiceLocator.Current in chained constructors were updated to GetRequiredService<T>().
-
EPiServer.Data.Validator.ValidateArgNotNull("paramName", value) call sites became ArgumentNullException.ThrowIfNull(value) for clearer, framework-standard null checks.
Search and Navigation vs Content Graph query pattern
Legacy-style pseudocode (Find)
var query = legacySearchClient.Search<SeoPageData>();
if (!string.IsNullOrWhiteSpace(term))
{
query = query.For(term);
}
query = query.Filter(x => !x.ExcludeFromSearch);
var result = query.Skip((page - 1) * pageSize).Take(pageSize).GetResult();
Graph-style pattern (same responsibilities)
var q = graphClient
.QueryContent<SeoPageData>()
.AsCurrentUser()
.SetLocale(currentCulture)
.WithDisplayFilters()
.WithCacheOptions(o => o.AbsoluteExpiration = TimeSpan.FromMinutes(5));
if (!string.IsNullOrWhiteSpace(term) && q is ISearchableContentQuery<SeoPageData> searchable)
{
q = searchable.SearchFor(term).WithPinned();
}
q = q.Where(x => x.ExcludeFromSearch != true);
q = ApplySort(q, selectedSortValue);
var graphResult = await q.IncludeTotal().Skip(skip).Limit(pageSize).GetAsContentAsync(ct);
Converting search queries to Graph (practical flow)
Configure Graph
"Optimizely": {
"ContentGraph": {
"GatewayAddress": "https://cg.optimizely.com",
"AppKey": "",
"Secret": "",
"SingleKey": ""
}
}
Convert full search query
public async Task<SearchResponse> SearchAsync(SearchRequest request, CancellationToken ct)
{
var q = _graphContentClient
.QueryContent<SeoPageData>()
.AsCurrentUser()
.SetLocale(CultureInfo.CurrentCulture)
.WithDisplayFilters()
.WithCacheOptions(o => o.AbsoluteExpiration = TimeSpan.FromMinutes(5));
if (!string.IsNullOrWhiteSpace(request.Term) && q is ISearchableContentQuery<SeoPageData> searchable)
{
q = searchable.SearchFor(request.Term).WithPinned();
}
q = q.Where(x => !x.ExcludeFromSearch);
var skip = (Math.Max(request.Page, 1) - 1) * request.PageSize;
var result = await q.IncludeTotal().Skip(skip).Limit(request.PageSize).GetAsContentAsync(ct);
return new SearchResponse
{
Total = result.Total ?? 0,
Items = result.Select(MapSearchHit).ToList()
};
}
Keep prompt / autocomplete aligned
Reuse the same composed query with a small window so prompts cannot diverge from full search rules:
var promptResult = await q
.IncludeTotal()
.Skip(0)
.Limit(5)
.GetAsContentAsync(ct);
Case-study takeaways
-
Biggest calendar sink: third-party packages and obsolete API removal not the Graph fluent API mapping itself.
-
Biggest quality win: one shared query composition function for term, filters, sort, scope, then different callers only change skip/limit (full page vs prompt).
-
Subtle regressions: sort order vs legacy Find, pinned behavior, and content area rendering after FilteredItems → Items.
-
Ops surprises: admin base path (/ui/CMS vs /Optimizely/CMS), SQL index conflicts on first-run migration, tab identifier rules see Upgrade to CMS 13 from CMS 12.
If the admin UI shows “No policy found” or “Unable to find a module by assembly”, a removed package often left policies or menu providers half-wired strip registrations and using directives completely.
Final recommendation
For a CMS 12 to CMS 13 migration, treat search conversion as an architecture task, not only a syntax task. Follow Optimizely’s ordered upgrade steps (framework, packages, AddCms(), Graph and Content Manager registration order, then breaking changes) and use the API replacement map when dotnet build surfaces unfamiliar errors. If you centralize scoping, filters, sorting, and paging in a graph-first pipeline, you get cleaner code, easier testing, and far fewer search regressions after release.
Comments