Back to blog

Content Variations: Optimizely CMS 13's Quiet Hero

Notes on the feature that didn’t get the keynote, but probably should have.

Every release has a headline act. CMS 13’s is the obvious trio. Visual Builder became the default editing surface. Optimizely Graph and Opti ID are now mandatory in every license. The whole platform moved to .NET 10. Those got the demo slots and the launch-day screenshots.

The feature I keep coming back to with clients isn’t any of them. It shipped quietly, got one modest line in the GA notes, and it’s going to change how editorial teams actually work day to day. Content Variations.

You only appreciate it after years of building the workarounds it kills. So let me make the case, and then, because these are field notes and not a brochure, show you where the sharp edges are and how we wrapped them.

What it actually is

Content Variations let an editor keep several published versions of the same content item, in the same language, from one source record. Not a copy in a sibling node. Not a language branch pressed into service as a fake A/B slot. The same item, with more than one published face.

The mechanics are better than the one-liner suggests. Variations are delta-based. A new variation starts with no property data and stores only the properties you actually change, not a full clone. Each one keeps its own version history and its own publishing lifecycle, and you can publish it independently of the original as long as the source itself is published. If the instance has a content-approval sequence configured, variations go through it too.

It shows up as a Variations dropdown in the editing toolbar, the “Select a variation of the page” control, with full autosave. You add one and name it. The name can’t include spaces and can’t start with a number. The docs use WinterCampaign as their example. Then you’re editing a divergent version in place. You can also seed a new variation from an existing one, which copies its content instead of starting from an empty delta. One structural rule: you can’t create a variation off Root.

When you have a winner, you promote it back into the canonical line. That’s where the first sharp edge hides, so hold that thought.

Why this is the quiet hero

Remember how we used to do this.

Want an A/B test on a landing page? You duplicated the page, ran it through whatever experimentation tool you’d bolted on, and prayed the URLs and canonical tags behaved. Want different content for different audiences? You either bought a separate personalization product or built a content area full of visibility rules until every page was a logic puzzle. Want a seasonal variant that didn’t disturb the permanent URL structure? You forked the tree and took on the cleanup debt.

All of those share one disease. They treat a variation of one thing as several different things. The tree grew. Reporting fragmented. Six months later nobody could say which of the four near-identical pages was the real one, and everyone was too scared to delete any of them.

Content Variations collapse that into one canonical record with several published expressions, and a single answer to “what is this page.” The experimentation, personalization, and campaign work that used to need structural gymnastics now sits inside the content item, where it belongs. It isn’t flashy. It’s structural, and structural fixes pay off for years.

The Graph angle is where it gets interesting

CMS 13 is cloud-first and headless. Delivery runs through Optimizely Graph. Classic ASP.NET MVC/Razor rendering still works, but Graph is the spine. So the real question isn’t whether editors can make variations. It’s how the front end gets the right one back. That’s Graph’s job, and the design choices here are worth reading closely, because they’re also where the risk lives.

Start with identity. Each variation is identified by a unique string held in a variation field on the content item, the variation key. That field is how you address a variation in a query. There’s deliberately no tidy GUID that means “this variation.” You select one by filtering on the key.

Then indexing. Every content variation, including unpublished drafts, is indexed to Graph with a unique identifier, so they’re discoverable for previews and experiments. The docs are careful about the result. A variation isn’t indexed inline with its source. Each one goes in merged with its source as a self-contained document, which is why a delivery query gets back fully resolved content instead of a bare delta. Treat the exact shape of the identifier as internal. It’s a composite of content GUID, version status, language, and variation key, sometimes written Guid_Status_Language_VariantKey, but that’s illustrative, not a contract. Address variations through _metadata (_metadata { key displayName }). Don’t string-split an id.

One default decides everything here.

By default, GraphQL queries don’t return variations at all. A standard query gives you only the original, canonical content. To get a variation, or to preview one, you opt in explicitly.

The opt-in is a variation argument. include is an enum (ALL | SOME | NONE), value is an array of variation keys, and includeOriginal is a boolean.

# Default: variations are invisible. Canonical content only.
query {
  _Content {
    items { _metadata { key displayName } }
  }
}

# Opt in to everything. Now you own whatever comes back.
query {
  _Content(variation: { include: ALL, includeOriginal: true }) {
    items { _metadata { key displayName } }
  }
}

# The disciplined form. Name the arms you actually want.
query {
  _Content(variation: { include: SOME, value: ["WinterCampaign"], includeOriginal: true }) {
    items { _metadata { key displayName } }
  }
}

The C# SDK (Optimizely.Graph.Cms.Query) mirrors this with SetVariation(...), so the discipline is the same whether you write GraphQL or build the query in C#.

That default is a safety valve, and a good one. A developer who’s never heard of variations writes the obvious query and never accidentally serves experimental content to the public. The flip side is that the moment you opt in, you own everything that comes back. Don’t assume a server-side default for includeOriginal. Set it. The SDK makes you anyway. SetVariation takes includeOriginal as a boolean in its direct overloads, like SetVariation(includeOriginal: true, "WinterCampaign"), and through the options object in the builder overload. Be that explicit in raw GraphQL too.

The part the launch post skipped: where this bites

This is the OMVP half, the part I’d want a client to read before they get excited.

1. Drafts are in the index, and include: ALL is a loaded gun

Unpublished variation drafts are indexed. The default protects you. The opt-in is yours to get wrong. If a delivery query reaches for ALL without pinning which variation it wants, or someone pastes a preview query into a production resolver, you can ship half-finished, experimental, or off-brand content straight to anonymous visitors. include: ALL belongs in preview and selection logic, never as a casual “give me everything.” When you want one arm, use include: SOME and name the key.

Keep one distinction straight. Indexed is not the same as returned. Drafts sit in the index, but anonymous queries still resolve to published content, and pulling unpublished variation content generally needs a preview-authenticated context. So the sharp public-leak risk is published variation content slipping out through an unscoped ALL, with draft leakage on top wherever a preview surface gets misused.

2. Promotion is two operations, and only one is dangerous

This is the bit I see told wrong most often, so let’s be exact.

“Copy changes to Original” pushes the variation’s modified (delta) properties back onto the original. If the original already has a published common draft, you get a new draft version of the original. It doesn’t overwrite the live published version, it doesn’t auto-publish, and version history stays intact. An editor still has to publish that draft. The UI even says “Changes copied to Original!” Nothing is destroyed, so this path is recoverable. It’s a merge into a new version, not a clobber.

Promoting a variation as the default version is the sharp one. It creates a new version of the source, merges the variation’s modified properties in, and can delete the variation afterwards. Because variations are deltas, only the properties the variation actually changed get merged. But deleting the variation takes that test arm’s separate history with it.

So the risk is real and specific. It lives in the promote-as-default-and-delete path, not in every promote. One confident click on the wrong option can wipe a test arm’s history for good. That needs process, not just permissions, and ideally a diff in front of the editor before the merge runs.

3. Deltas are bigger than editors think

Delta storage sounds surgical. It isn’t, quite. The granularity is property-level. Change one value inside a complex property and the whole property gets copied into the delta. There’s no finer sub-field delta. It bites hardest on Visual Builder Experience content. The Experience composition is itself a complex property, so changing a single block value pulls the entire Outline and its Sections into the delta. “I only touched the hero” is rarely true at the storage layer. That changes how much actually diverges, which changes what a promote or a re-sync does.

4. Reference integrity has gaps

Two limitations sit right there in the docs’ own Initial Phase Limitations.

Variations currently vary only localizable properties. Non-localizable, culture-invariant properties can’t diverge. The “currently” is the docs’ word, so treat it as release-bound and re-check it on whatever version you ship.

Softlinks aren’t generated for published variations. Any reference that exists only inside a variation, a link, a content-area item, a media usage, is invisible to the platform’s reference tracking.

The consequence is the same either way. If your governance, link-checking, or “where is this used” reporting leans on reference data, it’s partly blind to variation-only references. Add experimental URLs to that and you have a real duplicate-content and broken-reference risk that won’t show up where you normally look.

5. Governance scales with the surface, and most teams forget

Approval workflow covers variations if you’ve set it up. But variations multiply the number of things that can be published. If your approval gate only sits on the main editorial flow, you’ve quietly opened a side door. Nothing in the box stops a page from collecting fourteen orphaned variations nobody remembers making. Variation sprawl is the new tree sprawl. It’s just harder to see, because it doesn’t show up as nodes.

6. Index state is an environment footgun

A feature flag controls variation indexing, and flipping it off takes the index with it. Clean kill switch, and an easy thing to get wrong across environments. Flag and index state can drift, and an indexing gap is silent. A variation that should be live just isn’t in the index, your previews and experiments quietly vanish, and nothing throws. The pre-release content-variations docs call this out and the GA notes don’t repeat it, so confirm it on the build you ship. I wouldn’t hard-code a flag name in a runbook either. Treat it as the variations feature toggle and check it per environment. Silent failure is the worst kind.

7. Pulling variation sets at scale costs you on caching

This one is a general Graph property, and it’s documented. Broad items queries trash your cache. They force it to “account for potential changes across all content,” in the docs’ words, so it invalidates constantly. Narrow single-item lookups cache cleanly and hit far more often. The queries you write to pull variations across content are exactly the broad items kind. The docs don’t tie variations to caching directly, but the query shape you reach for is the one that caches worst. On a busy site that’s a real line item, not a footnote.

None of this is a reason to avoid the feature. It’s a reason to wrap it.

Where the add-on comes in: PiNo Labs Variations Auditor

Most of the risks above aren’t bugs. They’re sharp edges that come with a powerful primitive being honest about what it does. The platform gives you the capability and a sensible default. It doesn’t have opinions about how your team should use it. That gap is what a thin add-on fills.

In our Foundation solution we packaged the governance into a small CMS 13 add-on, PiNo.Labs.VariationsAuditor, that turns the variation estate from a blind spot into something you can see, measure, and act on. It installs as one self-registering protected shell module: an IConfigurableModule, a ProtectedModuleOptions self-registration, an /api/auditor/* REST surface, a shell menu entry, and a React UI embedded in the assembly and served straight from the DLL, with no wwwroot copy step. It authorizes against the canonical CMS roles Opti ID syncs onto the principal, so the gate behaves the same on DXP and locally. Every external dependency it leans on, Graph most of all, is resolved optionally and degrades gracefully when it’s missing.

A fair question before the code. Every platform seam the snippets touch (shell-module and menu registration, IConfigurableModule/ProtectedModuleOptions, the IContentEvents.PublishedContent hook, the versioning APIs) sits inside CMS 13’s breaking-change surface. So they’re shown as they compile against the CMS 13 / .NET 10 assemblies the add-on actually ships on, written for those breaking changes rather than refactored from CMS 12. Here’s what it does, mapped to the edges above.

It starts from the one inconvenient truth the platform hands you. There’s no in-process, site-wide API for “all content that has variations.” A CMS 13 content variation is just a content version carrying a non-empty IVersionable.Variation key, and a variation’s identity is a tuple of content, language, and variation key, not a GUID.

// A CMS 13 "content variation" is a version with a non-empty IVersionable.Variation key.
// There is no Guid for a variation; identity is the tuple. Discovery is delegated to Graph
// because no in-process API can answer "which content has variations" site-wide.
var variants = versionRepository
    .List(new VersionFilter { ContentLink = contentLink }, 0, max, out _)
    .Where(v => !string.IsNullOrEmpty(v.Variation));     // empty key == language baseline, not a variant

So discovery uses Graph to enumerate the site, then reads the authoritative variation keys from IContentVersionRepository. If Graph is down or hasn’t finished indexing, it falls back to an in-process content-tree walk. The grid works on day one, indexed or not.

A pre-promotion diff, so promotion is a decision and not a reflex (edges #2 and #3). Every destructive action runs a mandatory, conflict-aware dry-run first. The divergence engine computes a property-level diff and a structural content-area diff, so the editor sees exactly which properties will land back on the original. It also catches the dangerous case the platform won’t warn about: promoting a variation whose source has moved on since.

// Stale-conflict guard: refuse to silently overwrite newer master changes on promote.
var stale = divergence.GetStaleProperties(target);
if (action == BulkActionType.Promote && stale.Count > 0 && !cmd.ForceOverwriteStale)
{
    return Blocked(
        "CRITICAL REVERSION RISK: promoting would overwrite newer changes on the original for: " +
        string.Join(", ", stale) + ". Re-run with ForceOverwriteStale to proceed.");
}

Promote is a delta merge, full stop. It overlays only the properties the variation actually overrode onto a writable clone of the published original, and reports which ones it touched. That keeps CMS 13’s delta model intact instead of flattening master-only properties. We pulled an earlier “replace everything” mode because it broke exactly that model. The same dry-run, lock, and status gate guards Unpublish and Delete. The inverse action, “Sync from default,” lets an editor pull the original’s current value back into a stale variation, which is the safe fix for edge #3.

A site-wide orphan and sprawl report, so cleanup actually happens (edge #5). The grid is the inventory nobody had. Every variation across the site, the audience it targets (the key resolved to a readable visitor group, because editors think in audiences, not keys), its divergence state, and a needs-attention filter. Multi-select plus a dry-run bulk bar turns unpublishing or deleting a pile of orphaned arms into a two-click, fully previewed operation. Sprawl you can retire.

A deliverability probe that turns silent index failures loud (edges #4 and #6). This is the canary for “my variation should be live but isn’t in Graph.” It checks each published variant against the index, batched and de-duplicated per content id so a grid page costs one round-trip instead of N, and flags anything known to CMS but missing from the index as an orphan. Softlinks are blind to variations, so this Graph-backed inventory is the “where are my variations, and are they being served” view the reference system can’t give you. It doesn’t lie the other way either. If the Graph client isn’t registered at all, the probe returns deliverable rather than painting every variant red.

// Optional Graph client, never required. No AddGraphContentClient()? Degrade, don't crash.
_graph = serviceProvider.GetService();
...
if (_graph == null) return GraphStatus.Deliverable;        // unknown != orphan; never mislabel
var indexed = await IsIndexedAsync(identity.ContentId, ct);
return indexed ? GraphStatus.Deliverable : GraphStatus.IndexGap;   // IndexGap == orphan, surfaced in the grid

Proactive drift notifications, so editors come back when something changes (edge #5 again). A StaleDriftDetector subscribes to IContentEvents.PublishedContent. When a default publishes, it recomputes its variants’ staleness and tells the owner of each newly stale variation: the default just moved, your audience is now on yesterday’s content for these three properties. It’s bounded, de-duplicated per variant and save, runs in its own DI scope, and is wrapped so it can never fail the publish that triggered it. Delivery fans out across pluggable INotificationChannels (webhook to Slack/Teams, SMTP email, log), all off until the host opts in.

public void Initialize(InitializationEngine context)
    => context.Locate.ContentEvents().PublishedContent += OnDefaultPublished; // drift stops being silent

And the one guardrail that belongs at the delivery layer, not in the editor (edge #1). The auditor governs the editorial and estate side, which is the side it can see. The include: ALL problem is a delivery concern, and the cheapest place to close it is a thin convention around your Graph client. A typed query wrapper, or a CI lint over your resolvers, that refuses an unscoped ALL on the production delivery key and makes callers pass an explicit variation key or a preview context. The platform default already protects the naive query. This protects you from the clever one. Run both and you’ve covered what’s in the estate and what reaches the visitor.

The point isn’t the code. It’s the posture. The platform ships the engine and a sensible default. You ship the seatbelts that fit your team’s risk tolerance. That’s what an add-on is for.

The take

A year from now, Content Variations won’t be what people remember about CMS 13. Visual Builder and the headless story will take that slot. But it’s the feature that quietly removes a whole category of structural debt teams have carried for years, and it deserved more than one launch-day line.

Just go in with your eyes open. Drafts live in the index. The opt-in is yours to misuse. Promotion has two doors, and one of them deletes history. Deltas are coarser than they look. Softlinks and index state have edges. Put a thin layer of governance and tooling around the feature: a pre-promotion diff, a site-wide orphan report, a deliverability canary, a delivery-side guard. Then let your editors do the thing they’ve always wanted, which is to experiment on a page without turning the content tree into an archaeological dig.

Quiet hero. Sharp edges. Worth it.

Further reading

// tech stack
Optimizely CMS 13.NET 10Optimizely GraphGraphQLReact