Migrating from AEM to Optimizely SaaS CMS: Solving the Architectural Hurdles
Migrating a digital footprint from Adobe Experience Manager (AEM) to Optimizely SaaS CMS is a major milestone for any enterprise modernizing its digital experience platform. When you are dealing with years of accumulated legacy content spanning a large number of pages, it is tempting to treat this purely as a volume problem.
In reality, bulk loading data is the easy part. The real challenge lies in the architectural differences between how AEM and Optimizely SaaS CMS represent content structure, localization, and identity.
In this article, we will bypass the generic advice and focus on the high-stakes architectural hurdles you will encounter during an AEM-to-Optimizely migration — and how to solve them programmatically using the Optimizely SaaS CMS REST API.
The Sanity Checks: Start Simple
Before writing migration scripts or converting templates, always begin with a few basic sanity checks on your source JCR (Java Content Repository) data. Running these early prevents major pipeline failures later:
-
Clean up the ROT: Run an analysis in AEM to find Redundant, Outdated, and Trivial content. Look for pages with zero traffic over the last 12 months, expired pages (where offTime is in the past), or duplicate assets. Retiring this content before migrating saves significant engineering, QA, and content-review effort.
-
Verify target locales: Make sure all languages used in your AEM tree are explicitly registered in your Optimizely SaaS CMS instance before pushing content. Attempting to load localized content into an unregistered locale will cause API errors.
Once your basic environment is ready, you can tackle the primary architectural shifts.
Architectural Hurdle 1: Parallel Trees vs. The Single-Node Localization Model
One of the deepest conceptual mismatches between AEM and Optimizely is how they handle multi-language websites.
The Problem
AEM organizes languages using parallel folder trees. Under /content/mysite, you might have /en for English and /fr for French. These are completely separate nodes in the JCR, linked together externally via Multi-Site Manager (MSM) or Language Copy. If a French page exists under /content/mysite/fr/about but has no English equivalent under /content/mysite/en/about, AEM is perfectly happy.
Optimizely SaaS CMS uses a single-node localization model. A content item is represented by a single unique key. That single node has a primaryLocale — the master language — established permanently at creation. Any additional languages (such as French) exist as locale branches hanging off that same single node.
If your client's stated intent is that English must be the master language, but your discovery analysis reveals that a meaningful portion of regional pages or folders have no English equivalent, you have a structural conflict. You cannot create a French translation branch in Optimizely if the master English node does not exist.
The Solution: The Unpublished English Draft Shell
To keep a consistent English-master model without publishing placeholder or machine-translated English content that regional teams never requested, use the Unpublished English Draft Shell pattern.
When your migration pipeline encounters a French page with no English sibling, the loader should:
-
Create the content item with English (en) set as the master locale, carrying only non-localized properties and a fallback display name. Keep this version as an unpublished draft.
-
Create the French version (fr) as a translation branch on that same key and publish it.
Here is the two-step API flow:
Step 1: Create the master item in English (Unpublished Draft)
POST https://api.cms.optimizely.com/v1/content
Content-Type: application/json
{
"key": "5f2b9c1e-4a7d-4b8e-9c0d-1a2b3c4d5e6f",
"contentType": "standardPage",
"container": "parent-container-guid",
"initialVersion": {
"displayName": "About Us (FR Source — Master Shell)",
"locale": "en",
"properties": {
"heading": { "value": "Placeholder English Title" }
}
}
}
Step 2: Post the real French version to the same key
POST https://api.cms.optimizely.com/v1/content/5f2b9c1e-4a7d-4b8e-9c0d-1a2b3c4d5e6f/versions
Content-Type: application/json
{
"displayName": "À propos",
"locale": "fr",
"properties": {
"heading": { "value": "À propos de nous" }
}
}
After verifying the version was created, publish it:
POST https://api.cms.optimizely.com/v1/content/5f2b9c1e-4a7d-4b8e-9c0d-1a2b3c4d5e6f/versions/1:publish
This ensures your public-facing site remains clean, while maintaining a consistent and governable data model behind the scenes. Editors will see an empty English draft in the CMS — make sure your editorial team is briefed on this pattern so it does not cause confusion post-migration.
Architectural Hurdle 2: Making the Migration Re-runnable with Deterministic Keys
Migrations are rarely a one-and-done big bang. You will run dry runs, test waves, and delta syncs.
The Problem
If your migration loader relies on the target CMS to auto-generate random IDs upon creation, you lose idempotency immediately. If a script fails halfway through, running it again will result in duplicate pages, orphaned assets, and broken references. Maintaining a large lookup table mapping old AEM paths to new random GUIDs is fragile and prone to synchronization errors across environments.
The Solution: Deterministic Key Generation (UUID v5)
Optimizely SaaS CMS allows you to supply your own key (a valid UUID/GUID format) inside the request body when creating content.
You can exploit this by generating a deterministic UUID v5. UUID v5 uses a SHA-1 hash combining a fixed namespace UUID with a unique input string. In your migration pipeline, use AEM's highly stable jcr:uuid as the input name:
optimizely_key = uuidv5(MIGRATION_NAMESPACE_UUID, aem_jcr_uuid)
Because jcr:uuid is unique and survives page moves or renames inside AEM, your migration pipeline will generate the exact same Optimizely key every single time it processes a given item — on any machine, in any environment, with no coordination required.
This gives you native idempotency:
-
If the item already exists in Optimizely, your script performs a PATCH update.
-
If it does not exist, it performs a POST to create it.
-
If you need to roll back a wave, your script knows exactly which keys to target for hard deletion.
Architectural Hurdle 3: Content Type Immutability and the "Reparent & Swap" Pattern
During discovery, you classify AEM pages into Optimizely content types — some remain Pages, others (such as Experience Fragments or non-routed pages) become Shared Blocks.
The Problem
What happens if you load an item as a Page, and the editorial team identifies during UAT that it should have been a Shared Block?
In Optimizely, an item's contentType is immutable once created. You cannot change a Page to a Block because they inherit from different base classes and are stored differently by the platform — Pages have URL routing and hierarchy placement, Blocks do not. There is no API endpoint that converts one to the other.
Furthermore, if that incorrectly typed item is a parent container with a large number of correct children sitting beneath it, a hard delete of the parent will cascade and destroy all those child nodes.
The Solution: The "Reparent & Swap" Pattern
Rather than destroying and rebuilding the entire child hierarchy, execute an architectural swap via the REST API:
-
Create a temporary holding folder elsewhere in your content tree.
-
Move all children by updating each child's container property via a PATCH request to point to the temporary holder.
-
Hard-delete the now-empty parent, passing the special cms-permanent-delete header to release the key immediately rather than moving it to the Trash:
DELETE https://api.cms.optimizely.com/v1/content/bad-parent-guid
cms-permanent-delete: true
-
Recreate the parent using the exact same GUID key, this time supplying the correct contentType.
-
Move the children back by patching each child's container to point to the original GUID.
-
Hard-delete the temporary folder to leave no trace.
This pattern preserves every child's GUID, version history, regional locale branches, and any inbound content references from other pages — because none of that data was touched.
Architectural Hurdle 4: Applying the Swap Pattern to Language Versions
The Reparent & Swap concept extends naturally to localization issues, and this is where it becomes especially powerful during large migrations. There are two distinct language-version scenarios you are likely to encounter.
Scenario A: The "Locale Swap" — Changing the Master Language of a GUID
The primaryLocale of a content item is structurally immutable in Optimizely. If you created a page with English as the master, you cannot call an API to change it to French. However, if the business requires a specific GUID to have French as its master language, you can execute a Locale Swap using a temporary item — preserving the original GUID so all inbound links remain intact.
The Process
[Original: GUID_A] [Temp: GUID_T] [Restored: GUID_A]
Master: EN Master: FR (copy) Master: FR
Branch: FR Branch: EN (copy) Branch: EN
│ ▲ ▲
└── Read all properties ────────┘ │
└── Hard-delete GUID_A │
└── Recreate GUID_A with FR as master ─────────────────────┘
└── Hard-delete GUID_T
The API Steps
Step 1 — Read all version properties for both en and fr from the original item and hold them in your script.
Step 2 — Create a temporary item with French as the primaryLocale:
POST https://api.cms.optimizely.com/v1/content
{
"key": "temp-guid",
"contentType": "standardPage",
"container": "temp-holding-folder-guid",
"initialVersion": {
"displayName": "Temp Shell",
"locale": "fr"
}
}
Step 3 — Hard-delete the original item to release GUID_A:
DELETE https://api.cms.optimizely.com/v1/content/GUID_A
cms-permanent-delete: true
Step 4 — Recreate GUID_A with French as the master locale and all original French content restored.
Step 5 — Create the English translation branch on GUID_A with the original English content.
Step 6 — Publish according to the original publication state.
Step 7 — Hard-delete the temporary item.
The result: GUID_A now has French as its primaryLocale, English as its translation branch, and every page in the system that referenced GUID_A is completely unaware that anything changed.
Scenario B: The "Branch Swap" — Moving a Translation to the Correct Parent
In large migrations, AEM's parallel language trees can be misaligned during the bulk load — the French version of Page X ends up attached to Page Y as a locale branch instead. Since locale branches are bound to a specific GUID, you cannot directly reparent them. You must use a Read-Write-Delete approach.
The API Steps
Assume French (fr) is incorrectly sitting on Page_A but belongs on Page_B.
Step 1 — Read the French version's full property data from Page_A:
GET https://api.cms.optimizely.com/v1/content/Page_A/versions?locale=fr
Step 2 — Create a new French version branch on Page_B using the properties retrieved in Step 1:
POST https://api.cms.optimizely.com/v1/content/Page_B/versions
Content-Type: application/json
{
"displayName": "French Title",
"locale": "fr",
"properties": { ... }
}
Step 3 — Publish the new French branch on Page_B if the original was published:
POST https://api.cms.optimizely.com/v1/content/Page_B/versions/{newVersionId}:publish
Step 4 — Delete the incorrect French locale branch from Page_A. Locale branch deletion is immediate and irreversible — it does not go to Trash — which is exactly the behavior you need here:
DELETE https://api.cms.optimizely.com/v1/content/Page_A/locales/fr
Important note on locale deletion: Because deleting a locale branch is permanent with no recovery path, always verify that Step 2 and Step 3 have succeeded and the content is correctly visible in the target before executing Step 4. Build a mandatory verification check into your migration script before it fires the delete.
Building These Patterns into Your Migration Toolbox
All four patterns described above share a common foundation:
-
A Migration State Database that tracks every AEM node's original jcr:uuid, its deterministic Optimizely key, its content archetype, and its load status.
-
A purge/rollback mode in your loader that can fire cms-permanent-delete against a wave's key set — gated behind an explicit flag and environment check so it can never be accidentally pointed at production.
-
Structured logging with a run ID on every API call, so any partial failure can be diagnosed and resumed rather than restarted.
When these utility scripts exist from day one of your migration build, structural corrections that would otherwise cost days of manual recovery become a 20-minute scripted operation.
Conclusion
The architectural gap between AEM and Optimizely SaaS CMS is real, but it is entirely solvable with deliberate engineering. By designing your localization strategy around a consistent master language model, generating deterministic UUIDs to make your pipeline re-runnable, treating content type immutability as a design constraint rather than a blocker, and having the Locale Swap and Branch Swap patterns ready as operational utilities, you give your migration the resilience it needs to handle edge cases without unplanned rework.
Have you encountered similar structural challenges during platform migrations? Drop your experience in the comments below — the community would love to hear how you approached them.
Comments