Last updated: 2026-04-22
| Phase | Description | Status |
|---|---|---|
| Phase 1 | Remove incompatible packages, 0 build errors on CMS 12 | ✅ Complete |
| Phase 2 | Upgrade to .NET 10 / CMS 13 / Commerce 15, 0 build errors | ✅ Complete |
| Phase 3 | Runtime fixes — site returns HTTP 200, homepage loads | ✅ Complete |
| Phase 4 | Optimizely Graph + Content Manager, Find stubs replaced | ✅ Complete |
| Phase 5+ | Graph search implementation, QA, identity, data migration | 🔄 In progress |
- ✅ Category listing showing 0 products (
d05873a4) - ✅ Translation keys rendering verbatim (
e142db2e) - ✅ Homepage product block scoped to wrong catalog category (
3bb31e49) - ✅ QuickView X button didn't close modal —
data-dismiss→data-bs-dismissin_QuickViewModal.cshtml - ✅ Coupon Apply did nothing — JS syntax error fixed in
main.min.js;[FromQuery]→[FromForm]inDefaultCartController - ✅ Estimate Shipping 405 —
[FromBody]→[FromForm]inDefaultCartController.EstimateShipping - ✅ Credit Card form posts to wrong URL —
EditForm.cshtmlnow uses"CreditCard"as controller name inBeginForm - ✅ PlaceOrder 500 — address/payment update calls wrapped in try-catch in
CheckoutController.PlaceOrder - ✅ Dashboard 500 — null guard on
GetCurrentContact()inProfilePageController.Index - ✅ Dashboard 500 "View 'Save' not found" —
return View("Index", viewModel)explicit name inProfilePageController.Index(see CMS 13 action routing note below) - ✅ My Organization Users 500 — null guard on
currentOrganization?.SubOrganizationsinUsersController.Index - ✅ My Organization 500 — null guard on
currentOrganizationinOrganizationController.Index - 🔄 New Arrivals page product ordering differs from reference (data difference, low priority)
Upgrading the Optimizely Foundation reference implementation from CMS 12.31.2 to CMS 13.0.0.
| Component | Before | After |
|---|---|---|
| Optimizely CMS | 12.31.2 | 13.0.1 |
| Optimizely Commerce | 14.28.0 | 15.0.0-preview1 |
| .NET Target Framework | net6.0 | net10.0 |
| Search | EPiServer.Find 16.3.0 | Optimizely.Graph.Cms 13.0.1 |
Pre-upgrade additions already in the working tree: CMP integration, AdaptiveImages, TinymceDamPicker, ODP blocks, new infrastructure files (ContentApiPermissionsInit, CmsCmpPublishingPermissionsInit, GroupNamesCustom, OdpStory).
⚠️ These paths are machine-specific — do not rely on them in code.
- Build:
dotnet build src/Foundation/Foundation.csproj -c Release --nologo -v q - Publish output:
publish/(relative to working directory) - Deploy: stop IIS app pool →
dotnet publish ... -o publish→ start app pool - IIS site:
david_cms13-upgrade— app pool name matches site name - URL:
https://cms13-upgrade.opti-demo.online
Real credentials live in src/Foundation/appsettings.Development.json (gitignored — never committed).
appsettings.json contains only empty strings/placeholders; Optimizely DXP injects real values on hosted environments.
ASPNETCORE_ENVIRONMENT=Development is set on the local IIS app pool so appsettings.Development.json is loaded locally.
See src/Foundation/appsettings.example.json for the expected structure.
If SQL credentials need to be reset or re-entered, update src/Foundation/appsettings.Development.json directly.
Do not commit appsettings.Development.json — it is in .gitignore.
If the site returns 500 after a publish, check in this order:
- Event Viewer → Application log — look for the most recent .NET Runtime or IIS AspNetCore Module V2 error
- SQL login failure (
Login failed for user) — real credentials must be insrc/Foundation/appsettings.Development.json(gitignored). Ensure it exists with correct connection strings before publishing. - Graph Base-64 error (
not a valid Base-64 stringinEnsureSchemaInitializationModule/JwtPreviewTokenService) — the GraphSecretin appsettings is a placeholder or malformed. Must be a valid Base-64 string. - App pool keeps stopping — check Event Viewer for the crash exception, fix the root cause, then
Start-WebAppPool 'david_cms13-upgrade'.
- .NET 10 required — jump from net6.0 to net10.0
- Optimizely Graph mandatory — EPiServer.Find fully removed; replaced by Graph SDK
- Opti ID mandatory (PaaS) — replaces OpenID Connect / EPiServer identity stack
- Commerce 15 required — CMS 13 is NOT compatible with Commerce 14
- Site Definitions removed — replaced by Application model (
IApplicationResolver) - On-Page Editing opt-in — Visual Builder is new default; OPE requires
OnPageEditing = true - Plugin Manager removed —
EPiServer.PlugInsystem gone from Admin - Module URL namespace — changed from
/EPiServer/to/Optimizely/ - New built-in Content Manager — powered by Graph; EPiServer.Labs.ContentManager superseded
- New navigation tag helper —
<platform-navigation />replaces@Html.CreatePlatformNavigationMenu()
| Deprecated (v12) | Replacement (v13) |
|---|---|
PageReference |
ContentReference |
PageData.PageLink |
ContentLink |
IContentTypeRepository<PageType> |
IContentTypeRepository (non-generic) |
SiteDefinition.Current |
Inject IApplicationResolver |
context.Locate.Advanced / service locator |
Constructor injection via IServiceProvider |
@Html.CreatePlatformNavigationMenu() |
<platform-navigation /> tag helper |
| EPiServer.Find LINQ queries | Optimizely.Graph.Cms.Query fluent API |
| Package | Reason |
|---|---|
EPiServer.Find.Cms / EPiServer.Find.Commerce |
Replaced by Optimizely Graph |
EPiServer.ContentDeliveryApi.* (all) |
Requires CMS UI Core < 13.0.0 |
EPiServer.ContentDefinitionsApi |
Requires CMS UI Core < 13.0.0 |
EPiServer.ContentManagementApi |
Requires CMS UI Core < 13.0.0 |
EPiServer.ServiceApi.Commerce |
Requires Commerce.Core < 15.0.0 |
EPiServer.Session |
Targets net5/net6 only |
EPiServer.Telemetry.UI |
CMS 12 only |
EPiServer.Marketing.Testing |
Explicitly requires CMS < 13.0.0 |
EPiServer.Personalization.Commerce / MaxMindGeolocation |
No CMS 13 version |
UNRVLD.ODP.VisitorGroups |
No CMS 13 version |
Advanced.CMS.BulkEdit / GroupingHeader |
No CMS 13 version |
PowerSlice |
No CMS 13 version |
Geta.Optimizely.Categories / .Find |
No CMS 13 version |
Geta.NotFoundHandler.Optimizely |
No CMS 13 version |
Baaijte.Optimizely.ImageSharp.Web |
No CMS 13 version — use EPiServer.ImageLibrary.ImageSharp |
EPiServer.Labs.LanguageManager / ProjectEnhancements |
No CMS 13 version |
Optimizely.Labs.MarketingAutomationIntegration.ODP |
No CMS 13 version |
EPiServer.CMS.WelcomeIntegration.UI |
No CMS 13 version |
EPiServer.GoogleAnalytics |
No CMS 13 version — replace with direct GA4 tag |
EPiServer.PdfPreview |
No CMS 13 version |
TinymceDamPicker |
No CMS 13 version |
EPiServer.Forms.Samples |
Requires Forms < 6.0.0 and CMS < 13.0.0 |
EPiServer.Social.Framework |
Deprecated; removed by decision |
EPiServer.MarketingAutomationIntegration.ExactTarget |
Removed by decision |
EPiServer.Marketing.Automation.Forms |
Depends on ExactTarget |
EPiServer.Labs.ContentManager |
Superseded by built-in Content Manager |
AdaptiveImages / AdaptiveImages.Unsplash |
No CMS 13 version |
| Package | Version | Purpose |
|---|---|---|
Optimizely.Graph.Cms |
13.0.1 | Core Graph integration |
Optimizely.Graph.Cms.Query |
13.0.1 | C# fluent query API |
EPiServer.Cms.UI.ContentManager |
13.0.1 | Built-in Content Manager UI |
EPiServer.Cms.UI.AspNetIdentity |
13.0.1 | Decoupled identity |
EPiServer.OptimizelyIdentity |
13.0.1 | Opti ID integration |
| Package | From | To |
|---|---|---|
EPiServer.CMS |
12.31.2 | 13.0.1 |
EPiServer.Commerce |
14.28.0 | 15.0.0-preview1 |
EPiServer.Hosting |
12.21.7 | 13.0.1 |
EPiServer.CMS.TinyMce |
4.8.0 | 13.0.1 |
EPiServer.Forms |
5.10.7 | 6.0.0 |
Advanced.CMS.AdvancedReviews |
1.3.7 | 2.0.0 |
EPiServer.ImageLibrary.ImageSharp |
— | 13.0.1 |
SixLabors.ImageSharp |
2.x | 3.1.12 |
System.Configuration.ConfigurationManager |
6.0.1 | 10.0.2 |
System.Linq.Async |
6.0.1 | 7.0.0 |
<Compile Remove="Infrastructure\Find\**" />
<Compile Remove="Features\Locations\**" />
<Compile Remove="Features\Blocks\ProductFilterBlocks\**" />Non-Find files within Infrastructure\Find\ are individually re-included — see Foundation.csproj.
Find stubs remain in Features\Search\FindTypeStubs.cs.
Credentials are read from appsettings.json — do not hardcode.
"Optimizely": {
"ContentGraph": {
"GatewayAddress": "https://cg.optimizely.com",
"AppKey": "<AppKey>",
"Secret": "<Secret>",
"SingleKey": "<SingleKey>"
}
}Startup.cs registration order is critical:
services.AddContentGraph(_ => { }); // MUST be before ContentManager
services.AddContentManager();GetSiteSettings<T>(): do NOT bail onsiteId == Guid.Empty— a site may legitimately use an empty GUID as its IDUpdateSettings(): add GUID fallback whencontentRootService.List()returns empty (tblContentSourcemay be empty in a freshly migrated DB)- Call
InitializeSettings()early in theInitialize.Initialize()module initializer
Localized*Attribute classes (LocalizedRequired, LocalizedCompare, etc.) broke:
setting this.ErrorMessage then calling base.FormatErrorMessage() throws
"Either ErrorMessageString or ErrorMessageResourceName must be set, but not both."
Root cause: .NET 10's ValidationAttribute uses a DefaultMessageFactory Func internally.
Fix: return the localized string directly from FormatErrorMessage() — do not set this.ErrorMessage.
EPiServer.Personalization.Commerce was removed. CampaignVisitorGroupFilter (in Commerce.Marketing)
depends on IVisitorGroupRoleRepository. Register a NoOpVisitorGroupRoleRepository stub via
services.TryAddSingleton<> in Startup.cs after AddVisitorGroupsUI() so the real implementation
wins if ever provided.
In CMS 13, ContentAreaItem.ContentLink is ContentReference, not PageReference.
as PageReference silently returns null. Use new PageReference(contentLink.ID, contentLink.WorkID).
Fixed in: PageListBlockComponent.cs, HeaderViewModelFactory.cs.
Requires an explicit services.AddForms() call — no longer auto-registered by the CMS.
services.AddContentGraph() must be called before services.AddContentManager().
LinkItem objects returned from a LinkItemCollection content property are backed by a
ReadOnlyDictionary. Setting any property (.Title, .Text, .Href, .Target) on them throws
System.NotSupportedException: Collection is read-only.
This caused a 500 on every page render for authenticated users — the layout called
HeaderViewModelFactory.AddMyAccountMenu, which iterated layoutSettings.MyAccountMenu and set
linkItem.Title = linkItem.Text on the retrieved items.
Fix: never mutate a LinkItem from a content property — clone it first:
// WRONG — throws NotSupportedException in CMS 13
linkItem.Title = linkItem.Text;
menuItems.Add(linkItem);
// CORRECT — create a new LinkItem
var item = new LinkItem
{
Href = linkItem.Href,
Text = linkItem.Text,
Title = linkItem.Text,
Target = linkItem.Target
};
menuItems.Add(item);File: Features/Header/HeaderViewModelFactory.cs → AddMyAccountMenu().
In Optimizely CMS 13, Html.BeginForm("ActionName", "ControllerName", ...) resolves the form action
URL using content routing. For content pages this generates a URL like /en/page-url/ActionName/.
During this URL generation, the Optimizely routing infrastructure temporarily modifies route state with
action = "ActionName". This leaks into subsequent view name resolution: return View(model) (no
explicit view name) uses ActionDescriptor.RouteValues["action"] as the view name — which may now be
"ActionName" instead of the action method's own name.
Symptom: System.InvalidOperationException: The view 'Save' was not found on a page that has
Html.BeginForm("Save", "SomeController", ...) in its view.
Fix: always use an explicit view name in return View() on any controller whose view calls
Html.BeginForm with a different action name:
// WRONG — view name inferred from (potentially leaked) route data
return View(viewModel);
// CORRECT — explicit view name, immune to route data leakage
return View("Index", viewModel);Applied in: Features/MyAccount/ProfilePage/ProfilePageController.cs → Index().
Side effect: the canonical <link> tag on the rendered page will include the action segment
(/en/my-account/dashboard/Save). This is cosmetic and harmless for authenticated pages, but should
be addressed if the page needs correct SEO canonicalization.
A stale scheduled job record for EPiServer.Marketing.Testing.Web remains in the DB (tblScheduledItem).
Every scheduler tick logs FileNotFoundException to the Event Log but does not affect page rendering.
To silence it permanently: delete the row from tblScheduledItem and tblScheduledItemLog where
Name = 'Marketing Test Monitor'.
_contentLoader.Get<IContent>(r) as T silently fails for catalog entries in Commerce 15.
Commerce 15's content provider requires an entry-specific type; Get<IContent> takes a different
internal code path and returns a non-castable proxy.
// WRONG — returns null for ProductContent / VariationContent in Commerce 15
_contentLoader.Get<IContent>(r) as T
// CORRECT
_contentLoader.Get<EntryContentBase>(r) as TFile: Features/Search/SearchService.cs → GetCatalogEntries<T>().
Two root causes:
services.AddEmbeddedLocalization<Startup>()is still required in CMS 13 — do not remove it.- Optimizely's localization provider auto-scans the
lang/directory on disk at startup.EmbeddedResourcealone is not enough; files must also be physically deployed.
Fix:
<!-- Foundation.csproj — both entries required -->
<EmbeddedResource Include="lang\**\*" />
<Content Include="lang\**\*" CopyToPublishDirectory="PreserveNewest" />ProductSearchBlockComponent passed null as currentContent to SearchService.Search(),
causing it to fall back to the catalog root and return all products (in tree order).
Fix: inject IContentLoader, extract the first NodeContent from currentBlock.Nodes.Items,
and pass it as searchRoot. NodeContent extends CatalogContentBase, so SearchService uses its
ContentLink as the search root — scoping results to the configured category.
File: Features/Search/ProductSearchBlock/ProductSearchBlockComponent.cs.
IGraphContentClient (from Optimizely.Graph.Cms.Query) injected into SearchService.
services.AddGraphContentClient() added to Startup.cs (namespace: Optimizely.Cms.DependencyInjection).
OrderDirection enum is in Optimizely.Graph.Cms.Query.Implementation (unusual but correct).
Behaviour:
SearchContent(),SearchPdf()— Graph full-text onPageData/MediaData; no scoping needed.Search()with text query — Graph full-text onProductContent(no catalog-node scoping yet; see TODO in file).Search()with empty query — in-memory fallback (catalog browsing; preserves node scoping).QuickSearch(),SearchNewProducts()— Graph with in-memory fallback.- All Graph calls are wrapped in try/catch → fall back to in-memory if Graph is unavailable/unindexed.
TODO: add catalog-node scoping for product text search once ProductContent.ParentLink/Ancestors
field mapping in the Graph schema is confirmed. BuildFilter<T>().And(...) requires a lambda returning
GraphFilter (not bool), so == on ContentReference.ID needs an extension method from
Optimizely.Graph.Cms.Query.Filtering (e.g. Match(), In()).
| Area | Status | Notes |
|---|---|---|
| Graph search implementation | ✅ Done | IGraphContentClient injected; Graph primary path for all text searches; in-memory fallback retained |
| Locations feature | ❌ Excluded | Features/Locations/** excluded from compilation — needs Find geo-search replaced |
| ProductFilterBlocks | ❌ Excluded | Features/Blocks/ProductFilterBlocks/** excluded — Find Filter API has no direct Graph equivalent |
| SearchOnSale / SearchUsers | 🔄 Stub | Returns empty; needs Graph or Commerce API implementation |
| Commerce flow QA | 🔄 Pending | Cart → checkout → order not yet regression tested |
| Opti ID / auth | 🔄 Self-hosted | Currently using AddCmsAspNetIdentity (local auth); Opti ID requires DXP cloud |
| Content Graph indexing | 🔄 Pending | Initial sync not yet verified |
| Full regression QA | 🔄 Pending | Deeper crawl + manual commerce/forms/ODP testing |
| Risk | Severity | Mitigation |
|---|---|---|
| Commerce 15 is preview only | HIGH | Accepted; monitor for GA release |
| Many community add-ons have no v13 version | HIGH | Removed; track GitHub for v13 PRs |
| EPiServer.Find replaced — search uses Graph | LOW | Graph queries implemented; in-memory fallback active until indexing verified |
| UNRVLD ODP visitor groups not available | MEDIUM | ODP personalisation temporarily disabled |
| A/B testing (Marketing.Testing) not available | MEDIUM | Feature temporarily disabled |
| Geta NotFoundHandler not available | MEDIUM | No 404 redirect handling currently |
| TinyMCE DAM picker not available | LOW | DAM picker temporarily disabled |