Experience Sitecore ! | All posts tagged 'XM Cloud'

Experience Sitecore !

More than 300 articles about the best DXP by Martin Miles

Troubleshooting SitecoreAI Page Builder Stuck Forever on the Loading Spinner

Have you experienced that at least once? You open a page in SitecoreAI Page Builder, the page itself visually loads inside the editor iframe, you can clearly see the website content, but the editing UI never becomes available. Instead, the ng-spd-loading-indicator remains on top forever, blocking any interaction with the page.

That is a frustrating one, because at first sight the rendering host seems to work. The page is not blank. The app does not completely crash. There is no obvious "rendering host is unavailable" message. You may even see only one unrelated-looking browser console error, for example, something like a duplicate third-party script initialization.

In my case, the visible symptom was exactly that: Page Builder loaded the page content, but the Sitecore loading overlay never disappeared. The same fix had to be applied and verified on DEV, UAT, and PROD, with slightly different deployment mechanics per environment.

Let's walk through the troubleshooting route from the first false assumptions to the final fix.

Something to start with ...

Before digging into implementation details, it is worth refreshing how Page Builder rendering is supposed to work in XM Cloud / SitecoreAI.

The most important references are:

The key thing to remember is that Page Builder does not simply browse your public website. It talks to a rendering endpoint, typically:

https://<editing-host-or-rendering-host>/api/editing/render

That endpoint validates the editing secret, extracts Sitecore editing parameters, fetches editing layout data, renders the page with metadata, and returns markup that Page Builder can understand.

That "with metadata" part is important. A page that visually renders as a website is not automatically a page that Page Builder can edit.

What I First Suspected

When Page Builder hangs with a spinner, there are many tempting directions to investigate first. Some of them are absolutely valid in different cases.

1. Broken Rendering Host Configuration

The first obvious suspect is the rendering host item under:

/sitecore/system/Settings/Services/Rendering Hosts

For metadata-based editing, the important fields are:

Server side rendering engine endpoint URL:
https://<host>/api/editing/render

Server side rendering engine application URL:
https://<host>/

Server side rendering engine configuration URL:
https://<host>/api/editing/config

If these are wrong, Page Builder may fail early. In my case they were not the main reason, but they are still always worth checking.

2. Missing Environment Variables

The next suspect is environment configuration. The editing host and external rendering hosts need the right variables for Preview context and editing:

SITECORE_EDGE_CONTEXT_ID
NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID
NEXT_PUBLIC_DEFAULT_SITE_NAME
SITECORE_EDITING_SECRET

If one of those is missing, the render endpoint may return 401, 404, or 500 depending on where the application fails. I did find environment differences during the overall investigation, but fixing variables alone was not sufficient for the spinner problem.

3. Content, Presentation Details, or Personalization

Because the page content appeared in the iframe, it was natural to suspect some content-level issue:

  • broken final/shared presentation
  • component throwing only in edit mode
  • datasource item missing in Preview
  • personalization rule breaking layout service output
  • page relying on a rendering that does not support metadata

Those issues can absolutely break Page Builder. However, in this case the same application-level behavior appeared across pages and environments, which made a single content item less likely.

4. Experience Edge, Publishing, Indexing, or Links Database

Another direction is backend state:

  • item not published to Edge
  • stale Edge cache
  • index not rebuilt
  • links database not rebuilt
  • bad page route resolution

These are valid checks for missing content on a public site. But Page Builder editing uses Preview context and editing layout data. A public rendering problem and an editing render problem may look similar in the browser, but they are not the same thing.

5. Browser Console Errors

There was also a console error about duplicate initialization of a third-party SDK. It looked suspicious, but it was not the blocker. This is a good reminder: browser console noise is useful, but do not let the loudest warning own the investigation.

The Actual Symptom to Focus On

The important symptom was not simply "the page does not load".

The more precise symptom was:

The page content renders, but Page Builder never receives the successful editing-render completion it expects, so the ng-spd-loading-indicator never goes away.

This distinction matters a lot.

If a page is fully broken, you usually chase a rendering exception. If the page renders but Page Builder cannot edit it, you need to inspect the editing render flow.

The critical request is:

/api/editing/render

That is the request Page Builder makes to get editable metadata-backed markup.

How the Render Flow Is Supposed to Work

With Content SDK and Next.js, Page Builder calls the render API route with parameters similar to:

/api/editing/render
  ?secret=<editing-secret>
  &sc_site=<site-name>
  &sc_itemid=<item-id>
  &sc_lang=en
  &route=/
  &mode=edit
  &sc_version=latest
  &sc_layoutKind=shared

The SDK render middleware then:

  1. Validates the editing secret.
  2. Extracts the editing parameters.
  3. Enables preview/editing mode.
  4. Calls back into the application to render the requested page.
  5. Returns HTML that includes the metadata Page Builder needs.

That callback into the application is where this case became interesting.

The application was using App Router with a multisite route shape:

/{site}/{locale}/[[...path]]

The normal page route worked perfectly for public traffic. But for Page Builder, the default render handler was not explicit enough. The internal editing render request was going through the normal routing and middleware chain instead of a dedicated editing route.

That created a half-broken experience:

  • the page could visually render
  • middleware and routing still interfered with the editing request
  • Page Builder did not receive exactly the response it needed
  • the spinner never disappeared

The Fix: Create a Dedicated Editing Render Page

The fix was to stop sending the SDK render handler back through the normal public route and give Page Builder a dedicated private route for editing render.

The render API route changed from this:

import { createEditingRenderRouteHandlers } from '@sitecore-content-sdk/nextjs/route-handler';

export const { GET, POST, OPTIONS } = createEditingRenderRouteHandlers({});

to this:

import { createEditingRenderRouteHandlers } from '@sitecore-content-sdk/nextjs/route-handler';

export const { GET, POST, OPTIONS } = createEditingRenderRouteHandlers({
  allowedQueryParams: ['secret'],
  resolvePageUrl: () => '/sitecore-editing',
});

That tells the SDK:

  • preserve the secret query parameter
  • render editing requests through /sitecore-editing
  • do not guess the public page URL for the internal request

Then I added a private App Router page:

src/app/sitecore-editing/page.tsx

Its job is not to serve public traffic. Its job is to render a Sitecore page in editing mode after the SDK has transformed the original sc_* query parameters into the editing request.

The page does three important things.

First, it validates the request:

const CONTENT_SDK_PREVIEW_HEADER = "__content_sdk_preview";

if (
  !editingPreviewData ||
  requestHeaders.get(CONTENT_SDK_PREVIEW_HEADER) !== "1" ||
  secret !== scConfig.editingSecret
) {
  notFound();
}

Second, it constructs the editing preview data:

return {
  site,
  itemId,
  language,
  mode: mode === "edit"
    ? LayoutServicePageState.Edit
    : LayoutServicePageState.Preview,
  variantIds: getSearchParam(searchParams, "variantIds") || DEFAULT_VARIANT,
  version: getSearchParam(searchParams, "version"),
  layoutKind: getSearchParam(searchParams, "layoutKind") as LayoutKind | undefined,
};

Third, it fetches Sitecore preview data instead of public page data:

const page = await client.getPreview(editingPreviewData);

After that, it renders the same layout and providers as the normal public page. The difference is that the data source is now the editing preview data, and the route is isolated from public routing behavior.

Do Not Forget Middleware

This was the other important part.

If the application has middleware for multisite routing, localization, redirects, personalization, authentication, language cookies, or anything similar, that middleware may intercept /sitecore-editing.

For public traffic that is normal. For the internal editing render request, it is dangerous.

So the proxy/middleware needs to bypass this route:

export async function proxy(req: NextRequest, _ev: NextFetchEvent) {
  const langCookie = req.cookies.get(LANG_COOKIE_NAME)?.value;
  const pathname = req.nextUrl.pathname;

  if (pathname === '/sitecore-editing') {
    return NextResponse.next();
  }

  // normal middleware logic follows
}

That one small bypass is the difference between "the route exists" and "the route is actually usable by the editing render pipeline".

How I Verified the Fix

There are a few useful checks that do not require opening Page Builder first.

1. Check the Render API Route

The render endpoint should answer OPTIONS:

curl.exe -sS -o NUL -D - -X OPTIONS https://<host>/api/editing/render

A good response is:

HTTP/1.1 204 No Content
X-Matched-Path: /api/editing/render

That confirms the route exists on the deployed host.

2. Check the Private Editing Route

Direct browser access to /sitecore-editing should not show the page. It should be protected:

curl.exe -sS -o NUL -D - https://<host>/sitecore-editing

Expected result:

HTTP/1.1 404 Not Found
X-Matched-Path: /sitecore-editing

This is a very useful result. It means the route is deployed and matched, but the security checks are working because the direct request does not include the internal preview header and secret.

3. Check the Actual Page Builder Network Flow

After that, open Page Builder and watch the Network tab.

The request to /api/editing/render should complete. If it returns a normal error, troubleshoot that error. If it returns an HTML page that looks like a public route, check that resolvePageUrl is actually pointing to /sitecore-editing and that middleware is not rewriting it.

Deployment Gotchas Across DEV, UAT, and PROD

The code fix was the same, but deployment was not.

In DEV, the application branch already contained the shared render helper, so the fix was straightforward and went through the development branch.

In UAT, the same fix had to be deployed both to the Sitecore editing host and the external Vercel rendering host, because Page Builder may use either depending on the rendering host item configuration.

In PROD, there was an additional twist: the production editing host was tied to main, not the development branch. Therefore, blindly pushing the full development branch would have been wrong. The fix had to be backported narrowly to main, including only:

src/app/api/editing/render/route.ts
src/app/sitecore-editing/page.tsx
src/proxy.ts

Then the production editing host was deployed from main, and the production Vercel projects were deployed manually because Git deployments were disabled for those projects.

That is another important lesson: when fixing Page Builder, do not assume the public rendering host and the Sitecore editing host are deployed by the same mechanism. Check both.

The Minimal Checklist

If I had to reduce the whole troubleshooting route to a checklist, it would be this:

  1. Confirm Page Builder is failing at the editing render stage, not just public page rendering.
  2. Check /api/editing/render on the host configured in Sitecore.
  3. Confirm the rendering host item points to the metadata endpoint, not an old /jss-render endpoint.
  4. Confirm SITECORE_EDITING_SECRET and Edge preview variables exist on the host.
  5. Check whether App Router, middleware, redirects, localization, or multisite proxy logic intercepts editing requests.
  6. Add a dedicated /sitecore-editing route for App Router editing render.
  7. Point createEditingRenderRouteHandlers to that route with resolvePageUrl.
  8. Bypass middleware for /sitecore-editing.
  9. Deploy both the Sitecore editing host and any external rendering host Page Builder may use.
  10. Verify OPTIONS /api/editing/render and protected direct access to /sitecore-editing.

Final Thoughts

This problem is confusing because it does not look like a classic crash. The site renders. The content is visible. The browser may even show unrelated console errors. But Page Builder is still blocked because visual rendering is only half of the contract.

For SitecoreAI Page Builder, the rendering host must return an editing-aware, metadata-enabled response. With Next.js App Router and custom middleware, the safest approach is to give the SDK render handler a dedicated private route and keep that route out of the normal public routing pipeline.

Once that was done, the spinner disappeared and the editing experience started behaving normally across the environments.

Sometimes the fix is not in the component that appears broken, nor in the content item being edited. It is in the invisible bridge between Page Builder and the rendering host.

SitecoreAI Containers Without Docker Desktop

If you have ever tried to run SitecoreAI containers locally and hit the Docker Desktop licensing wall, you are not alone. Docker Desktop requires a paid subscription for organizations with more than 250 employees or an annual revenue of more than $10 million. That forces a question: can you run the XM Cloud local container stack without Docker Desktop at all?

The short answer is yes, but the full picture is more nuanced. I went through Sitecore's official documentation, the starter kit repositories, and every community walkthrough I could find to map out what is actually supported, what is community-derived, and where the gaps are. 

The approach is to install and run the standalone Docker Engine directly as a Windows service, then use Docker Compose from the CLI plugin path that modern Docker tooling expects. This is useful when Docker Desktop is unavailable, undesired, restricted by licensing, or unstable in a local Sitecore development environment.

1. Already got Docker Desktop? Remove it first

If Docker Desktop is already installed, stop it first. If you are replacing it entirely, uninstall Docker Desktop before installing the standalone Docker Engine.

You may also want to clean old Docker resources before continuing, especially if your Windows container layers have grown very large.

A related cleanup approach from Vikrant Punwatkar’s Docker cleanup guidance uses a longer shutdown timeout for Docker daemon cleanup:

dockerd -D --shutdown-timeout 900

This can help when Docker needs more time to clean large Windows container layers.

2. Enable required Windows features

If you used Docker Desktop previously, you'll likely have the following features available; if not, run PowerShell as Administrator:

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All
Enable-WindowsOptionalFeature -Online -FeatureName containers -All

Restart Windows if prompted.

Both Hyper-V and Containers are commonly required for Windows container development.

3. Download standalone Docker Engine for Windows

Run PowerShell as Administrator:

curl.exe -o docker.zip -LO https://download.docker.com/win/static/stable/x86_64/docker-20.10.13.zip

Extract it:

Expand-Archive docker.zip -DestinationPath C:\

After extraction, Docker binaries should be available under:

C:\docker

4. Add Docker to PATH

Run this as a single line in Administrator PowerShell:

[Environment]::SetEnvironmentVariable("Path", "$([Environment]::GetEnvironmentVariable('Path', 'Machine'));C:\docker", [System.EnvironmentVariableTarget]::Machine)

Then refresh the current shell session path:

$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine")

Close and reopen PowerShell if docker is still not recognized.

5. Register Docker daemon as a Windows service

Register Docker Engine as a Windows service:

dockerd --register-service

Start the Docker service:

Start-Service docker

Verify Docker is running:

docker version
docker info

Test a basic container run:

docker run hello-world

6. Install Docker Compose v2 CLI plugin

Create the Docker CLI plugin directory:

New-Item -ItemType Directory -Force -Path "C:\Program Files\Docker\cli-plugins"

Download Docker Compose v2:

curl.exe -L "https://github.com/docker/compose/releases/download/v2.18.1/docker-compose-windows-x86_64.exe" -o "C:\Program Files\Docker\cli-plugins\docker-compose.exe"

We also need adding it to PATH by using the below PowerShell in admin mode;

$d='C:\Program Files\Docker\cli-plugins'; $m=((([Environment]::GetEnvironmentVariable('Path','Machine') -split ';') | ? { $_ -and $_  -ne "$d\docker-compose.exe" -and $_ -ne $d } | select -Unique) + $d) -join ';';  [Environment]::SetEnvironmentVariable('Path',$m,'Machine'); $env:Path=$m

Verify Compose:

docker compose version
docker-compose version

The important path is:

C:\Program Files\Docker\cli-plugins\docker-compose.exe

This path allows docker compose to work as a Docker CLI plugin.

7. Configure Docker daemon if needed

Create or edit this file:

C:\ProgramData\docker\config\daemon.json

A safe baseline configuration for many Sitecore Windows container setups is:

{
  "experimental": true,
  "features": {
    "buildkit": false
  }
}

Restart Docker after changing daemon configuration:

Restart-Service docker

For Sitecore projects, experimental: true may be needed when the solution mixes Windows and Linux containers. This is common in some older Sitecore container setups.

8. Stop local IIS and Solr before starting Sitecore containers

Local IIS or Solr services can conflict with Sitecore container ports.

Stop IIS:

iisreset /stop

If you have a local Solr Windows service installed, stop it from services.msc, or stop it by service name if you know it:

Stop-Service -Name "solr-*"

If that wildcard does not match your service, list services first:

Get-Service *solr*

9. Run your Sitecore project

From the project root, the command depends on the project structure.

For many SitecoreAI local container setups that may look as below.

If you have not run it ever before, you must initialize it first:

cd .\local-containers\scripts
.\init.ps1 -InitEnv -LicenseXmlPath C:\Projects\license.xml -AdminPassword b -baseOs ltsc2022

Then just run the main script:

.\local-containers\scripts\up.ps1

Now sit comfortable, grab your coffee and wait until it pulls all the required images and runs:

Typical Sitecore local container startup scripts may do several things:

  • Pull or update images
  • Build custom images
  • Configure hosts file entries
  • Start Docker Compose services
  • Start the CM container
  • Install Sitecore CLI plugins
  • Populate Solr schemas
  • Rebuild indexes
  • Push serialized items
At the end, you will see SitecoreAI authentication screen in a browser:

Troubleshooting checklist

Before going into specific issues, I just want to advise you to check if you're running scripts in the admin mode - that is especially crucial on the corporate and enterprise restricted machines.

Docker service does not start

Check the service:

Get-Service docker
Start-Service docker

Check Docker info:

docker info

If Docker fails with references to panic.log, try removing this file and starting Docker again:

Remove-Item "C:\ProgramData\docker\panic.log" -Force
Start-Service docker

Docker command is not recognized

Confirm Docker is in the machine PATH:

[Environment]::GetEnvironmentVariable("Path", "Machine")

Confirm the binary exists:

dir C:\docker\docker.exe

Refresh current PowerShell PATH:

$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine")

Docker Compose is not recognized

Confirm the plugin exists:

dir "C:\Program Files\Docker\cli-plugins\docker-compose.exe"

Verify Compose:

docker compose version

Ports are already in use

Check common Sitecore container ports:

netstat -ano | findstr ":443"
netstat -ano | findstr ":8079"
netstat -ano | findstr ":8984"
netstat -ano | findstr ":14330"

If IIS is using port 443:

iisreset /stop

Windows container layers consume too much disk space

Windows container layers can accumulate under Docker’s storage folders, especially after repeated Sitecore container rebuilds.

Common cleanup commands:

docker system df
docker system prune -a

For stubborn Windows layer cleanup, a longer Docker daemon shutdown timeout may help:

dockerd -D --shutdown-timeout 900

Use caution when manually deleting Docker layer folders. Prefer Docker’s own cleanup commands first.

How to run multiple SitecoreAI Docker instances simultaneously on a single host machine

If you've ever worked on multiple SitecoreAI (XM Cloud) projects at the same time, you know the pain. Often you have to terminate working on Project A, run docker compose down, wait, switch .env files, run docker compose up, wait some more... and then realize you need to quickly check something back in Project A. Rinse, repeat, lose your sanity. What a time waste!

What if I told you there's a way to run three completely independent XM Cloud Docker environments side by side, all on the same Windows machine, all accessible on standard HTTPS port 443, with full network isolation between for each of them?

Let's dig into how I made it work.

The Problem - Why Can't We Just Run Three Docker Compose Stacks?

At first glance, running three copies of the XM Cloud starter kit seems straightforward. Clone the repo three times, change some ports, and done. Right?

Not quite. There are two showstoppers:

1. The Hostname Lock-In

Sitecore's XM Cloud identity server (Auth0) expects callbacks to xmcloudcm.localhost or *.xmcloudcm.localhost. This is hardcoded on their end. Every single CM instance in the standard setup uses xmcloudcm.localhost as its hostname. If three CM containers all claim the same hostname, only one wins.

2. The Traefik Port War

Each codebase ships with its own Traefik reverse proxy, and all of them want port 443 and port 8079. Docker will happily start the first one and reject the rest due to a port conflict.

And there are secondary conflicts too - MSSQL (port 14330) and Solr (port 8984) also collide.

The Solution - A Shared Traefik Gateway with Subdomain Routing

The architecture I landed on is built around three key ideas:

One Traefik to rule them all. Instead of three competing Traefik instances, we run a single shared Traefik at the root level that acts as the gateway for all environments.

Third-level subdomains for CM. Since Auth0 accepts *.xmcloudcm.localhost, we use one.xmcloudcm.localhost, two.xmcloudcm.localhost, and three.xmcloudcm.localhost. One wildcard TLS certificate covers all three, and the codebases configured as below:

  ┌────────────┬─────────────────────────────────────────────┬────────────┬───────────┐
  │  Codebase  │                 CM Hostname                 │ MSSQL Port │ Solr Port │
  ├────────────┼─────────────────────────────────────────────┼────────────┼───────────┤
  │ codebase-1 │ https://one.xmcloudcm.localhost/sitecore/   │ 14331      │ 8984      │
  ├────────────┼─────────────────────────────────────────────┼────────────┼───────────┤
  │ codebase-2 │ https://two.xmcloudcm.localhost/sitecore/   │ 14332      │ 8985      │
  ├────────────┼─────────────────────────────────────────────┼────────────┼───────────┤
  │ codebase-3 │ https://three.xmcloudcm.localhost/sitecore/ │ 14333      │ 8986      │
  └────────────┴─────────────────────────────────────────────┴────────────┴───────────┘

A shared bridge network for Traefik routing. CM and rendering containers from each codebase join a shared nat network so Traefik can reach them, while internal infrastructure (MSSQL, Solr) stays isolated on each project's default network.

Here's the high-level picture:

                     +----------------------+
                     |   Shared Traefik     |
                     |   Port 443 / 8079    |
                     +--+-------+-------+---+
                        |       |       |
                   traefik-shared (nat network)
                        |       |       |
   +----------+--+  +---+------+---+  +--+----------+
   | codebase-1  |  | codebase-2   |  | codebase-3  |
   |  xmc-one    |  |  xmc-two     |  |  xmc-three  |
   | MSSQL:14331 |  | MSSQL:14332  |  | MSSQL:14333 |
   | Solr:8984   |  | Solr:8985    |  | Solr:8986   |
   +-------------+  +--------------+  +-------------+

And the hostname mapping should loke as:

  ┌─────────────┬─────────────────────────────────────────────┐
  │   Service   │                  Hostname                   │
  ├─────────────┼─────────────────────────────────────────────┤
  │ CM 1        │ https://one.xmcloudcm.localhost/sitecore/   │
  ├─────────────┼─────────────────────────────────────────────┤
  │ CM 2        │ https://two.xmcloudcm.localhost/sitecore/   │
  ├─────────────┼─────────────────────────────────────────────┤
  │ CM 3        │ https://three.xmcloudcm.localhost/sitecore/ │
  ├─────────────┼─────────────────────────────────────────────┤
  │ Rendering 1 │ https://nextjs.xmc-one.localhost/           │
  ├─────────────┼─────────────────────────────────────────────┤
  │ Rendering 2 │ https://nextjs.xmc-two.localhost/           │
  ├─────────────┼─────────────────────────────────────────────┤
  │ Rendering 3 │ https://nextjs.xmc-three.localhost/         │
  └─────────────┴───────────────────────────────────────────���─┘

What We Need to Change

The beauty of this solution is how little needs to change. Let me walk through every modification.

Step 1: The Shared Traefik

Create a shared-traefik folder at the root of your multi-docker directory. It gets its own docker-compose.yml:

services:
  traefik:
    isolation: hyperv
    image: traefik:v3.6.4-windowsservercore-ltsc2022
    command:
      - "--ping"
      - "--api.insecure=true"
      - "--providers.docker.endpoint=npipe:////./pipe/docker_engine"
      - "--providers.docker.exposedByDefault=false"
      - "--providers.file.directory=C:/etc/traefik/config/dynamic"
      - "--entryPoints.websecure.address=:443"
      - "--entryPoints.websecure.forwardedHeaders.insecure"
    ports:
      - "443:443"
      - "8079:8080"
    healthcheck:
      test: ["CMD", "traefik", "healthcheck", "--ping"]
    volumes:
      - source: \\.\pipe\docker_engine\
        target: \\.\pipe\docker_engine\
        type: npipe
      - ./traefik:C:/etc/traefik
    networks:
      - traefik-shared

networks:
  traefik-shared:
    name: traefik-shared
    external: true

The key insight here: a single Traefik connects to the Docker engine via a Windows named pipe, enabling it to discover all containers across all Compose projects. It joins the traefik-shared network to actually reach the CM and rendering containers.

Before starting Traefik, create the shared network with the Windows nat driver:

docker network create -d nat traefik-shared

Why nat and not bridge? Windows containers don't support the bridge driver. If you try docker network create traefik-shared without -d nat, you'll get: "could not find plugin bridge in v1 plugin registry". This was one of the first gotchas I ran into.

The TLS config (shared-traefik/traefik/config/dynamic/certs_config.yaml) references wildcard certificates:

tls:
  certificates:
    - certFile: C:\etc\traefik\certs\_wildcard.xmcloudcm.localhost.pem
      keyFile: C:\etc\traefik\certs\_wildcard.xmcloudcm.localhost-key.pem
    - certFile: C:\etc\traefik\certs\_wildcard.xmc-one.localhost.pem
      keyFile: C:\etc\traefik\certs\_wildcard.xmc-one.localhost-key.pem
    - certFile: C:\etc\traefik\certs\_wildcard.xmc-two.localhost.pem
      keyFile: C:\etc\traefik\certs\_wildcard.xmc-two.localhost-key.pem
    - certFile: C:\etc\traefik\certs\_wildcard.xmc-three.localhost.pem
      keyFile: C:\etc\traefik\certs\_wildcard.xmc-three.localhost-key.pem

Step 2: Parameterize the Traefik Labels

This is the most important change to the existing codebase, and also the most subtle.

When Traefik uses the Docker provider, it reads labels from containers to build its routing table. In the stock docker-compose.yml, the CM service has labels like:

- "traefik.http.routers.cm-secure.rule=Host(`${CM_HOST}`)"

The router name cm-secure is hardcoded. If three CM containers all define cm-secure, Traefik merges them into a single router with unpredictable results.

The fix is elegant: prefix every router, middleware, and service name with ${COMPOSE_PROJECT_NAME}:

- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-cm-secure.rule=Host(`${CM_HOST}`)"

Since each codebase has a unique COMPOSE_PROJECT_NAME in its .env (e.g., xmc-one, xmc-two, xmc-three), the router names become xmc-one-cm-secure, xmc-two-cm-secure, etc. Globally unique. Zero conflicts.

This same change applies to all Traefik label lines in both docker-compose.yml (CM labels) and docker-compose.override.yml (rendering host labels).

I also parameterized the MSSQL and Solr host ports while I was in there:

# Was: "14330:1433"
ports:
  - "${MSSQL_PORT:-14330}:1433"

The :-14330 default means if you don't set MSSQL_PORT, nothing changes. Backward compatible.

Step 3: The Multi-Instance Override

Each codebase gets a docker-compose.multi.yml that does three things:

  1. Disables the per-codebase Traefik (since the shared one handles everything)
  2. Connects CM and rendering to the shared Traefik network
  3. Passes the site name to the rendering container
services:
  traefik:
    deploy:
      replicas: 0

  cm:
    labels:
      - "traefik.docker.network=traefik-shared"
    networks:
      - default
      - traefik-shared

  rendering-nextjs:
    environment:
      NEXT_PUBLIC_DEFAULT_SITE_NAME: ${SITE_NAME:-xmc-one}
    labels:
      - "traefik.docker.network=traefik-shared"
    networks:
      - default
      - traefik-shared

networks:
  traefik-shared:
    name: traefik-shared
    external: true

The traefik.docker.network label is critical - it tells the shared Traefik which network to use when routing to this container. Without it, Traefik might try to route via the wrong network and fail silently.

This file is loaded automatically via the COMPOSE_FILE variable in .env:

COMPOSE_FILE=docker-compose.yml;docker-compose.override.yml;docker-compose.multi.yml

Windows gotcha: The COMPOSE_FILE separator on Windows is ; (semicolon), not : (colon). Using colons causes a cryptic CreateFile ... The filename, directory name, or volume label syntax is incorrect error. This one took me longer to figure out than I'd like to admit.

Step 4: Per-Codebase .env Configuration

Each codebase's .env gets a handful of unique values:

Variablecodebase-1codebase-2codebase-3
COMPOSE_PROJECT_NAMExmc-onexmc-twoxmc-three
CM_HOSTone.xmcloudcm.localhosttwo.xmcloudcm.localhostthree.xmcloudcm.localhost
RENDERING_HOST_NEXTJSnextjs.xmc-one.localhostnextjs.xmc-two.localhostnextjs.xmc-three.localhost
MSSQL_PORT143311433214333
SOLR_PORT898489858986
SITE_NAMExmc-onexmc-twoxmc-three

Don't forget SITECORE_FedAuth_dot_Auth0_dot_RedirectBaseUrl - it must match the CM hostname, for example:

SITECORE_FedAuth_dot_Auth0_dot_RedirectBaseUrl=https://one.xmcloudcm.localhost/

Step 5: The Hosts File

This is pretty simple - just add these entries to C:\Windows\System32\drivers\etc\hosts:

127.0.0.1	one.xmcloudcm.localhost
127.0.0.1	two.xmcloudcm.localhost
127.0.0.1	three.xmcloudcm.localhost
127.0.0.1	nextjs.xmc-one.localhost
127.0.0.1	nextjs.xmc-two.localhost
127.0.0.1	nextjs.xmc-three.localhost

Step 6: Fix up.ps1

The up.ps1 script has a Traefik health check that uses the hardcoded router name cm-secure@docker. Since we parameterized it, we need to read COMPOSE_PROJECT_NAME and use it:

$composeProjectName = ($envContent | Where-Object {
    $_ -imatch "^COMPOSE_PROJECT_NAME=.+"
}).Split("=")[1]

# Updated health check
$status = Invoke-RestMethod "http://localhost:8079/api/http/routers/$composeProjectName-cm-secure@docker"

And the hardcoded Start-Process https://xmcloudcm.localhost/sitecore/ becomes:

Start-Process "https://$xmCloudHost/sitecore/"

Making the Next.js Rendering Hosts Work

Getting the CM to respond on a subdomain turned out to be the easy part. Wiring up the Next.js rendering hosts - that's where the real fun began.

The "Configuration error" Wall

After the CM instances were humming along on their subdomains, I turned my attention to the rendering hosts. Each codebase's rendering-nextjs container mounts the Next.js app from examples/basic-nextjs and runs npm install && npm run dev as its entrypoint. Simple enough. Except all three containers immediately crashed with:

Error: Configuration error: provide either Edge contextId or
local credentials (api.local.apiHost + api.local.apiKey).

The issue? The stock sitecore.config.ts ships as a bare defineConfig({{}}) - no API configuration at all. This works when you're connecting to XM Cloud Edge in production, but in local Docker mode, the Content SDK's build tools need to know where the CM lives.

The fix is to add the api.local block:

import { defineConfig } from '@sitecore-content-sdk/nextjs/config';

export default defineConfig({
  api: {
    local: {
      apiKey: process.env.SITECORE_API_KEY || '',
      apiHost: process.env.SITECORE_API_HOST || '',
    },
  },
  defaultSite: process.env.NEXT_PUBLIC_DEFAULT_SITE_NAME || 'xmc-one',
  defaultLanguage: 'en',
});

The SITECORE_API_HOST is set to http://cm by the compose override (the internal Docker DNS name for the CM container), and SITECORE_API_KEY comes from the .env file. So far so good.

The "Invalid API Key" Surprise

With the config in place, I restarted the rendering containers. They got further this time - the SDK successfully connected to the CM - but then:

ClientError: Provided SSC API keyData is not valid.

Here's the thing about Sitecore API keys: having a GUID in your .env file isn't enough. That GUID must be registered as an item inside Sitecore's content tree at /sitecore/system/Settings/Services/API Keys/. Without it, Sitecore rejects the key outright.

The standard up.ps1 flow handles this via the Sitecore CLI's serialization push, but since we're running three parallel instances, we need to do it for each:

cd codebase-1
dotnet sitecore cloud login                    # Browser auth required
dotnet sitecore connect --ref xmcloud `
  --cm https://one.xmcloudcm.localhost `
  --allow-write true -n default

& ./local-containers/docker/build/cm/templates/import-templates.ps1 `
  -RenderingSiteName 'xmc-one' `
  -SitecoreApiKey 'c46d10a5-9d62-4aa9-a83d-3e20a34bc981'

This creates the API key item under /sitecore/system/Settings/Services/API Keys/xmc-one with CORS and controller access set to *. The browser login is interactive (Auth0 device flow), but you only need to do it once per codebase.

The "//en" Path Mismatch Mystery

After registering the API keys and restarting the containers, the rendering hosts started successfully. Next.js reported Ready in 6.2s. I opened https://nextjs.xmc-one.localhost/ in the browser, and... "Page not found."

The container logs told the story:

[Error: Requested and resolved page mismatch: //en /en]
GET / 404 in 53ms

That double slash in //en was the clue. The Content SDK's multisite and locale middleware were fighting over path resolution. The locale middleware was adding /en to the path /, but without a defaultSite configured, the multisite resolver was confused about which site to use, producing a mangled double-slash path.

Two changes fixed it:

  1. Set defaultSite in sitecore.config.ts (shown above) - this tells the SDK which Sitecore site to resolve by default
  2. Pass NEXT_PUBLIC_DEFAULT_SITE_NAME as an environment variable to the rendering container via docker-compose.multi.yml

The Hostname Binding

Even with the path mismatch fixed, the rendering host still returned 404. The CM's layout service was responding correctly when queried directly - I verified this with:

curl -sk "https://one.xmcloudcm.localhost/sitecore/api/layout/render/jss?item=/&sc_apikey=&sc_site=xmc-one&sc_lang=en"

That returned valid JSON with the Home page route data. So the CM was fine. The problem was in Sitecore's site resolution.

In the Sitecore Content Editor, each site has a Site Grouping item with a Hostname field. This field tells Sitecore which incoming hostname maps to which site. Without it, when the rendering host queries the CM for layout data, Sitecore doesn't know which site the request is for.

The fix: go to each CM's Content Editor and set the Hostname to the rendering host's domain:

  • xmc-one site -> Hostname: nextjs.xmc-one.localhost
  • xmc-two site -> Hostname: nextjs.xmc-two.localhost
  • xmc-three site -> Hostname: nextjs.xmc-three.localhost

After that, all three rendering hosts returned HTTP 200, each serving content from their respective Sitecore instance. Three independent XM Cloud stacks, all running in parallel, all on standard HTTPS.

Running It All

First-Time Setup

# Run as Administrator
cd C:\Projects\SHIFT-AI\Multiple-Docker
.\setup-multi.ps1

This creates the Docker network, generates wildcard TLS certificates with mkcert, and updates the hosts file.

Then initialize each codebase (if not already done):

cd codebase-1\local-containers\scripts
.\init.ps1 -InitEnv -LicenseXmlPath C:\License\license.xml -AdminPassword "YourPassword"
# Repeat for codebase-2 and codebase-3

Daily Usage

# Start everything
.\start-all.ps1

# Start just one codebase
.\start-all.ps1 -Codebases @("codebase-2")

# Stop everything
.\stop-all.ps1

# Stop codebases but keep Traefik running (faster restarts)
.\stop-all.ps1 -KeepTraefik

Post-Startup: Register API Keys and Create Sites

After the CMs are healthy, you need to register the API keys and create sites with hostname bindings (see the sections above). This is a one-time step per codebase.

Verifying It Works

Check the Traefik Dashboard

Open http://localhost:8079/dashboard/. You should see six routers - two per codebase (CM + rendering), each with its unique name prefix.

Hit Each CM Instance

  • https://one.xmcloudcm.localhost/sitecore/ -> Auth0 login redirect
  • https://two.xmcloudcm.localhost/sitecore/ -> Auth0 login redirect
  • https://three.xmcloudcm.localhost/sitecore/ -> Auth0 login redirect

Hit Each Rendering Host

  • https://nextjs.xmc-one.localhost/ -> Site home page (HTTP 200)
  • https://nextjs.xmc-two.localhost/ -> Site home page (HTTP 200)
  • https://nextjs.xmc-three.localhost/ -> Site home page (HTTP 200)

All three should serve their respective Sitecore site content, each powered by an independent CM, MSSQL, and Solr instance.

Lessons Learned

Windows Docker networking has its own rules. Networks must use nat. A container can't reliably join multiple nat networks. I originally designed three separate Traefik networks for strict isolation but had to collapse them into one shared nat network. In practice, only CM and rendering containers join the shared network - MSSQL and Solr stay isolated on their project-default networks.

COMPOSE_FILE uses semicolons on Windows. The Linux docs show COMPOSE_FILE=a.yml:b.yml:c.yml. On Windows, it's a.yml;b.yml;c.yml. Using colons produces a cryptic CreateFile ... volume label syntax error that doesn't immediately suggest a separator issue.

Traefik label naming is global. This was the non-obvious gotcha. When Traefik uses the Docker provider, router names are global across all containers it discovers. Two containers defining the same router name creates a conflict that Traefik resolves silently (and wrongly). Prefixing with ${COMPOSE_PROJECT_NAME} is a pattern I'll use in every multi-project Docker setup from now on.

Docker Compose labels merge, they don't replace. When you override a service in a compose override file, labels from the base file persist. You can add new labels but can't remove old ones. This is why the parameterization approach (changing the base file) is cleaner than trying to override labels.

sitecore.config.ts needs explicit local API config. The stock defineConfig({{}}) assumes Edge mode. For local Docker development, you must specify api.local.apiKey and api.local.apiHost. The Content SDK's build tools (sitecore-tools project build) fail at startup without them.

defaultSite prevents the //en path mismatch. Without it, the multisite and locale middlewares can't agree on path resolution, producing //en instead of /en. This manifests as every page returning 404 even though the CM has valid content. Setting defaultSite in the config and passing NEXT_PUBLIC_DEFAULT_SITE_NAME as an environment variable resolved this instantly.

API keys must be registered in Sitecore, not just in .env. Having a GUID in SITECORE_API_KEY_APP_STARTER is necessary but not sufficient. The GUID must exist as a Sitecore item under /sitecore/system/Settings/Services/API Keys/. The import-templates.ps1 script + dotnet sitecore ser push handles this.

Sitecore sites need hostname bindings. Even with correct API keys and config, the rendering host gets 404 until the site's Hostname field in Sitecore matches the rendering host's domain. The CM's layout service uses this to resolve which site a request belongs to.


What Could Be Simplified Further

  • Template-driven .env generation: A script that generates .env files for N codebases, not just three.
  • Compose profiles: Instead of deploy.replicas: 0, use Docker Compose profiles to toggle between standalone and multi-instance modes.
  • Single-codebase fallback: Remove the COMPOSE_FILE line from .env to revert any codebase to standalone mode instantly.
  • Automated API key registration: A script that generates, registers, and configures the API key for each codebase in one step.

The total delta is about 10 lines changed per codebase in the Docker compose files, a sitecore.config.ts update, and a handful of new infrastructure files at the root. Not bad for what felt like an impossible requirement.

If you've been juggling multiple XM Cloud projects and constantly stopping and starting Docker environments, I hope this saves you some time and frustration. Drop a comment if you've found other approaches or run into edge cases - I'd love to hear about them.


GitHub (not fully operable, must be fine-tuned in some way)

Sitecore PowerShell as a SitecoreAI Marketplace app

I have reverse-engineered the Sitecore PowerShell Extension and reassembled it into a SitecoreAI Marketplace app!

This PoC is essentially a blueprint app. On its own, it is a universally working example. Still, its real benefit comes from this app providing a ready-to-use boilerplate that can be reused to build a variety of specialized SitecoreAI Marketplace apps on top of the SPE approach.

The most awesome feature is that all of your Marketplace context values could be easily passed into a remotely executed PowerShell script as a script parameter: there is a convenient Script Parameters mapping with maramater token preview (so that you never miss the right value!)

Here's what it looks like:

To enable SPE with Remoting:

  1. From SitecoreAI Deploy, add SPE_REMOTING_SECRET environmental CM secret, then re-deploy.
  2. Install a package, which will enable SPE with Remoting: SPE_with_Remoting.zip (3.93 kb)
  3. This package also adds a disabled sitecore\speremoting user with admin rights, which you must enable manually from User Manager

Once this setup is complete, the environment is fully prepared to use the app. The Marketplace app supports multitenancy; however, SPE Remoting must be enabled individually for every environment you intend to work with.


Top 10 Mistakes Organizations Make on Sitecore XM Cloud Implementations

Sitecore XM Cloud represents a monumental shift in how we build and deliver digital experiences. The promise of a fully managed, composable DXP is incredibly compelling, and for good reason. However, after seeing numerous projects in the wild and talking with fellow developers and architects, I've noticed a pattern of common, time-consuming, and often costly mistakes. These aren't the simple "RTFM" errors; they are the insidious problems that stem from underestimating the fundamental paradigm shift from traditional Sitecore XP to a headless, SaaS world.

I've spent the last couple of years deep in the trenches of XM Cloud, and I've seen what works and what causes projects to grind to a halt. In this post, I’m going to walk you through the top 10 mistakes I see organizations making time and time again. My goal is to save you the headaches, the late nights, and the budget overruns. Let's take a look at them.

1. Migrating Information Architecture Without Restructuring First

This is, without a doubt, the single biggest mistake I see teams make, and it poisons the well for the entire project. It seems logical to start by moving your content, but on XM Cloud, that's a recipe for disaster. Failing to address Information Architecture first is the source of all the further downstream chaos.

In the world of Sitecore XP, we had a lot of flexibility. You could have mixed templates in content folders, no clear separation between page structure and data items, and site definitions buried deep in the content tree. XM Cloud, on the other hand, is ruthless in its demand for a clean, predictable structure. It expects a strict hierarchy: Tenant → Site → Content.

Why It Becomes a Nightmare

When you try to push a legacy XP content tree into XM Cloud, the entire system starts to break in subtle and frustrating ways:

  • Serialization Fails: The dotnet sitecore ser push command will throw errors about non-unique paths or other inconsistencies that were perfectly valid in your old instance.
  • Component Bindings Break: Pages might render, but components won't bind to their datasources correctly because the expected folder structures don't exist.
  • Pages Editor is Unusable: Content authors will complain about missing fields, broken placeholders, and a generally unusable editing experience.
I cannot stress this enough: before you migrate a single piece of content, you must first migrate your IA. This means creating a clean, XM Cloud-native IA in your new project, and then mapping your old content to that new structure. It feels like extra work upfront, but it will save you weeks of painful debugging down the line.

2. Ignoring Experience Edge Rate Limits and Caching Architecture

Once you get past the initial IA migration, the next major pitfall I see is a fundamental misunderstanding of Experience Edge. We get so excited about the idea of a globally replicated, high-performance content delivery network that we forget it operates under specific rules and limitations. As I covered in one of my own posts, you absolutely must know these limitations before you start building.

Experience Edge is not a magic black box. It has hard limits that can and will break your application in production if you don't design for them from day one. Here are the critical ones:

Limit Type

Constraint
Impact of Ignoring
API Rate Limit
80 requests/second
HTTP 429 errors, service unavailability
GraphQL Query Results
1,000 items per query
Incomplete data requires pagination
GraphQL Query Complexity
~250 (undocumented)
Queries fail with complexity errors
Default Cache TTL
4 hours
Stale content for up to 4 hours

The Architectural Imperative: Cache Everything

The 80 requests/second limit seems generous, but it's a cap on uncached requests. A single user visiting a server-side rendered (SSR) page with multiple components could generate a dozen or more GraphQL queries. In a high-traffic scenario, you will hit that limit almost instantly. This is why Sitecore's guidance, and my own experience, dictate that you must architect your solution with a cache-first mindset. This means leveraging Static Site Generation (SSG) and Incremental Static Regeneration (ISR) wherever possible.

Trying to retrofit a caching strategy onto a chatty, SSR-heavy application after it's already built is a nightmare. You must plan your component rendering strategies from the beginning. Ask yourself for every component: - "Can this be statically rendered? Does it need to be server-side rendered? Can we use client-side fetching for dynamic elements?". Ignoring these questions is a direct path to production performance issues and emergency redesigns.

3. Underestimating Non-SXA to Headless SXA Migration Complexity

This is a big one, and it often comes as a nasty surprise to teams migrating from older, non-SXA Sitecore XP instances. The assumption is that you can just lift your existing components and content structure and somehow make them work in a headless fashion. The reality is that XM Cloud expects Headless SXA as its baseline. If your legacy solution isn't built on SXA, you are not performing a simple migration; you are performing a complete architectural rebuild.
I've seen teams budget for a straightforward content migration only to discover that their entire presentation layer is fundamentally incompatible. Migrating from a non-SXA site can add an extra month or two to a project easily, depending on the complexity.

Why It's a Rebuild, Not a Migration

Headless SXA enforces a strict, convention-based approach to site structure and presentation that simply doesn't exist in non-SXA builds. Here’s what you’re actually signing up for:

  • Rebuilding Structure: You must manually recreate your site architecture using SXA conventions (Tenant → Site → Page Branches → Data).
  • Rebuilding Layouts: All of your existing layouts must be rebuilt as Page Designs.
  • Rebuilding Shared Components: Headers, footers, and other shared elements must be recreated as Partial Designs.
  • Converting All Renderings: Every single one of your renderings must be converted into a Headless SXA-compatible JSS component.
  • Remapping Placeholders: Your old custom placeholders won't work as-is. The placeholder mapping in XM Cloud is far more rigid and requires a complete overhaul.
Failing to account for this massive effort is one of the fastest ways to blow your budget and timeline. If you are coming from a non-SXA background, you must treat the project as a replatforming exercise, not a simple upgrade.

4. Assuming MVC Backend Customizations Can Be Migrated Directly

For years, the power of Sitecore development lay in its extensible backend. We built custom renderField pipelines, hooked into countless processors, and used dependency injection to create powerful, server-side logic. In XM Cloud, that world is gone. I’ve seen teams spend weeks trying to figure out how to migrate their complex backend code, only to realize that it’s a futile effort. If your old solution contains backend customization, you must find a way to implement it in the head application.

This is a fundamental paradigm shift that many seasoned Sitecore developers struggle with. XM Cloud is a headless-first platform, which means your .NET code is running in a black box, completely decoupled from the rendering host. There is no way to directly influence the rendered output from the backend.

The Headless Mindset Shift

Here’s what this means in practice:

  • renderField Pipelines are Obsolete: Any logic that modifies field rendering at request time must be moved to your Next.js components.
  • Controller Logic Must Be Refactored: Your MVC controller actions that process data or modify component output must be re-implemented as stateless services or APIs that your Next.js application can call.
  • Dependency Injection is Different: Your custom services and logic can't be injected into rendering pipelines anymore. A great alternative I've seen work well is using PageProps factory plugins in the Next.js application to fetch and inject data into your components.
Trying to shoehorn your old MVC patterns into XM Cloud will lead to nothing but frustration. You have to embrace the headless mindset and move your logic to where it now belongs: the head application.

5. Not Understanding Next.js Routing and Configuration Fragility

One of the most common cries for help I see on community channels is, - "I just set up my project, and every page is a 404!". This is almost always the result of underestimating the sheer fragility of the Next.js routing and configuration setup in an XM Cloud project. There is a big number of moving parts that all have to work correctly to get pages to display.

This isn't a single point of failure; it's a dozen small, interconnected dependencies that can break the entire application. It’s a classic “needle in a haystack” problem that can burn hours, if not days, of a developer's time.

The House of Cards: Common Failure Points

If you're hitting routing issues, here’s a checklist of the most likely culprits I've seen:

  • The [[...path]].tsx file: This is the heart of Sitecore's catch-all routing. If this file is accidentally renamed, if a merge conflict corrupts it, or if the special bracket characters are wrong, all your Sitecore-managed routes will fail.
  • Site Name Mismatches: The siteName in your .env files must perfectly match the site name configured in Sitecore under /sitecore/content/MyTenant/MySite/Settings/Site Grouping/MySite. A tiny typo will break everything.
  • Environment Variable Loading: Remember that Next.js loads environment files in a specific order (scjssconfig.json.env.env.local). A misconfigured variable in one file can silently override the correct value in another.
  • Layout Service Failures: The [[...path]].tsx file relies on a successful response from the Layout Service. If your API key is wrong, your JSS Editing Secret doesn't match, or the rendering host items at /sitecore/system/Settings/Services/Rendering Hosts are misconfigured, the Layout Service will fail, and your app will render a 404.
  • Special Folder Names: Next.js has special folder names like pages and app. If a git merge accidentally creates an empty folder with one of these names in your rendering host's file system, it can confuse the Next.js router into thinking no routes exist.
Troubleshooting this requires a methodical approach. You have to check every single one of these connection points, from your local environment files all the way to your CM instance configuration. There are no shortcuts.

6. Overlooking Serialization Duplicate Item Issues

Serialization is the backbone of modern Sitecore development, but it has its own set of quirks that can bring a project to a standstill. One of the most frustrating issues I’ve seen is the "Non-unique paths cannot be serialized" error. This problem, as detailed by Brad Fettes, is both familiar and maddeningly tedious to resolve.

This error typically occurs when you have two items with the same name under the same parent item. While Sitecore’s database is perfectly happy with this (since it uses GUIDs for identity), the file system is not. When the serialization process tries to create two YAML files with the exact same name in the same directory, it fails. The most common culprit? Duplicate __Standard Values items.

How the Trap is Set

This usually happens when developers are working in parallel on different environments. Here’s a typical scenario:

  1. Developer A creates a new template and its standard values on their local machine and serializes them.
  2. Before Developer A commits and pushes their changes, Developer B creates the same template and standard values on a shared development environment.
  3. Developer A pushes their code. Now, the serialized YAML for the standard values exists in the repository.
  4. When you try to pull these changes and push them to the shared environment, the serialization engine sees a conflict: the GUID from the YAML file is different from the GUID of the item that already exists in the Sitecore database, even though the path is identical. The result: Non-unique paths cannot be serialized.

The Painful Manual Fix

The error message suggests renaming the item, but that’s not an option for __Standard Values. The only way to fix this is a manual, repetitive process:

  1. Copy the second GUID from the error message.
  2. Paste it into the search bar in the Content Editor of the target environment to find the offending item.
  3. Manually delete the duplicate item.
  4. Re-run the dotnet sitecore ser push command.
  5. Repeat the process if you get another error for a different item.
This is a huge time sink, and it highlights the fragility of a file-based serialization strategy when multiple developers are making content changes. It’s a stark reminder that in the XM Cloud world, a disciplined Git workflow and clear communication about who is creating which items are more critical than ever.

7. Trusting Undocumented Platform Behavior

One of the hardest lessons to learn when moving to a SaaS platform like XM Cloud is that you are no longer in control of the underlying infrastructure. Things can and will change without notice. A painful example of this surfaced in mid-2024, when deployments across numerous projects began failing due to mysterious compilation errors: an unannounced, breaking change in how the XM Cloud build process consumed the xmcloud.build.json file.

For months, the build process had seemingly ignored the buildTargets property, defaulting to using the solution (.sln) file. Then, one day, Sitecore deployed a change that strictly enforced this property. Teams that had (logically) pointed this to their specific project (.csproj) file suddenly found their deployments failing because the new build process couldn't restore NuGet packages correctly. The error messages about missing assemblies were completely misleading, sending developers on a wild goose chase for a problem that wasn't in their code.

The Sobering Reality of SaaS

This incident is a perfect illustration of a new class of problems we face with XM Cloud:

  • Undocumented Breaking Changes: The platform is constantly evolving, and not every change is going to be announced in advance. What worked yesterday might be broken today for reasons entirely outside your control.
  • Misleading Error Messages: The errors you see are often symptoms of a deeper platform issue, not a problem with your own code, which makes troubleshooting incredibly difficult.
  • Reliance on Support: The only way this issue was diagnosed was through a Sitecore support ticket. You must be prepared to engage with support and provide detailed logs to resolve these kinds of problems.
My advice: when a previously working process suddenly breaks for no apparent reason, and your local builds are fine, your first suspect should be an unannounced platform change. Document your troubleshooting steps meticulously and open a support ticket sooner rather than later. Don't waste days debugging your own code when the platform itself may have shifted under your feet.

8. Neglecting Local Development Environment Complexity

One of the great promises of XM Cloud was a simplified local development setup. While it has improved in many ways, I’ve found that many teams underestimate the complexity and fragility of the Docker-based environment. It is far from a "plug and play" experience and is a significant source of friction, especially for developers new to the project or to Docker itself.

The official Sitecore documentation provides a good starting point for troubleshooting, but it also reveals just how many things can go wrong. These aren't edge cases; they are common, everyday problems that can cost your team hours of productivity.

The Daily Hurdles of Local Development

Here are some of the recurring issues that turn local setup into a constant battle:

  • The down.ps1 Ritual: If you shut down your machine without running the down.ps1 script, your containers are left in an exited state. The next time you run up.ps1, it will fail. You have to remember to run the cleanup script every single time. It’s a small thing, but it’s a constant papercut.
  • Corporate Network and Firewall Policies: This is a huge one. I’ve seen countless hours lost because a company's security policies block communication between containers or prevent access to the Docker DNS. The solutions often involve switching to hyperv isolation or getting firewall rules changed, which can be a bureaucratic nightmare.
  • DNS Configuration: Sometimes the CM container will fail to authorize on startup due to invalid DNS settings. The fix is to manually set the DNS in your Docker Desktop settings to a public one like 8.8.8.8, but this is not documented in the standard setup guides and is something you only discover after hours of frustration.
  • The "Unhealthy" Container: Seeing your cm container in an "unhealthy" state is a rite of passage for XM Cloud developers. Debugging it requires you to manually check the Docker logs, exec into the container, and curl the health check endpoint to figure out what went wrong. It’s a tedious and opaque process.
Don't treat the local development environment as a given. You need to budget time for setup, troubleshooting, and creating internal documentation for your specific network environment. A smooth local setup is not a luxury; it's a prerequisite for a productive team.

The good news is that since the introduction of Metadata Editing Mode in 2025, developers can progress with an FE-first approach, which allows scaffolding a front-end app and developing it against the remote XM Cloud environment, avoiding all the hassles of dealing with local Windows-based containers.

9. Missing SXA Feature Parity Gaps

For teams that have been using SXA on-premise for years, there’s a natural assumption that the features they rely on will be available in XM Cloud. This assumption is often wrong and can lead to significant unplanned development work. I’ve seen this myself on projects where clients were extensive users of features like Snippets, Scriban, Content Tokens, or Overlays, only to discover during migration that these features simply don’t exist in the current version of Headless SXA.

This leaves development teams with a difficult choice: either abandon the functionality or rebuild it from scratch in a headless world.

Rebuilding What Was Once Out-of-the-Box

This isn’t a trivial task. Take Content Tokens, for example. To replicate this functionality, you can’t just write a simple helper function. A proper implementation requires:

  1. A Custom GraphQL Query: You need to create a GraphQL query to fetch the token data, similar to how the Dictionary Service works.
  2. A Custom Service: You need a service that fetches this data and, crucially, caches it in memory to avoid hammering the Experience Edge API on every page load, especially during a static build.
  3. Integration with PageProps: This service must be integrated into the PageProps factory plugin to make the token data available to all your components.
This is a significant amount of work to replicate a feature that was once a standard part of SXA. It’s a classic example of the “rebuild, don’t migrate” reality of XM Cloud. You must perform a thorough audit of all SXA features your existing solution uses and verify their availability in XM Cloud before committing to a migration plan. Don’t assume feature parity.

10. Failing to Plan for Workflow and Multilingual Migration Complexity

Finally, I want to touch on two areas that are often treated as afterthoughts but are fraught with hidden complexity: workflows and multilingual content. These are the silent killers of a migration project. The issues don’t show up as significant, loud deployment errors; they manifest as confusing problems for content authors long after the initial migration is supposedly “done.”

Because you no longer have direct database access, you can’t just run a SQL script to fix things when they go wrong. You are entirely at the mercy of your serialization and import tools.

The Silent Failures

Here’s what often goes wrong:

  • Workflows Break Silently: During content import, items can be created without their original workflow state, or XM Cloud might reject an invalid workflow ID from your old instance. The result is that editors can’t publish migrated content, and nobody knows why until they try. Auto-trigger actions also frequently fail due to missing commands. The only solution is to manually map and reassign workflows to thousands of items after the import, a tedious and error-prone task.
  • Multilingual Content Gets Corrupted: Migrating multilingual sites is exponentially more complex. I’ve seen shared fields get overwritten by an import from a different language, layout variations between languages get lost, and media items have inconsistent versioning. These are incredibly difficult problems to untangle without direct database access.
These issues highlight the need for meticulous planning and, most importantly, rigorous verification. A migration isn’t successful when the import script finishes. It’s successful when you have clean verification reports, and your content authors have confirmed that they can edit, publish, and see their content correctly in all languages.

Conclusion

The move to Sitecore XM Cloud is an exciting and necessary evolution for the platform. However, it is not a simple upgrade. It is a fundamental paradigm shift that requires a new way of thinking about architecture, development, and deployment. The biggest mistakes I’ve seen are not the result of bad code, but of bad assumptions - assuming that what worked in the on-premise world will work in a composable, SaaS world.

My advice is to approach your first XM Cloud project with a healthy dose of humility. Assume nothing. Question everything. Budget time for learning, for troubleshooting, and for the inevitable “unknown unknowns.” The platform is powerful, but it demands respect for its complexity.

I hope this list helps you avoid some of the pitfalls I’ve seen. If you’re struggling with your XM Cloud implementation or planning a migration, I’m always happy to share my experiences. Feel free to drop me a message!

Resolving broken images issue on XM Cloud starterkit running in local containers

A guide on how to resolve the media problem with the default vanilla StarterKit (foundation head) when images do not show on a site when accessed by a hostname.

Reproducing the problem

  1. Pull the latest starter kit, run init.ps1 , and up.ps1 as normal. Everything is fresh and clean; nothing else than that.
  2. When CM spins up, create a site collection followed by a site with the default name (nextjs-starter) matching the name of the JSS app.
  3. Open the home page in Experience Editor and drop an Image component to that page, upload and choose the image, save, and confirm it looks fine in EE.
  4. Open the home page again on the site: https://nextjs.xmc-starter-js.localhost and see the error

The error

Invalid src prop (https://cm/-/jssmedia/Project/Tenant/Habitat/LA.jpg?h=408&iar=0&w=612&ttc=63890432287&tt=4BBE52A4592C2DBCAE59361096C0E4D3&hash=0CA75E384F533EE5539236785DCF0E22) on `next/image`, hostname "cm" is not configured under images in your `next.config.js`

What happens?

The hostname of a local CM container is applied to the generated media URL (https://cm). That is not right, as CM hostname is not accessible outside of containers, and only works within the internal Docker network at the HTTP protocol, not HTTPS. After playing with the image URL, it was clear it works by a relative path, trimming the hostname from the start. Both URLs below are valid and show the image:

Testing an assumption

I thought if there was a problem on the datasource resolver of the Image component, but the resolver wasn't set. Just for the sake of experiment, decided to explicitly set image component to use `/sitecore/system/Modules/Layout Service/Rendering Contents Resolvers/Datasource Resolver`, since it has the same effect as when not set, along with unchecking Include Server URL in Media URLs settings for that resolver.

And on the resolver, unchecking as below:

That effectively resolved the issue. My guess was valid, so let's now seek a permanent solution.

The working solution

Once the assumption of removing a hostname is proven, let's take it out at the CM level so that it universally applies to your environments and you don't need to precisely set resolvers on each individual component. The below config patch does exactly what we need.

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/">
  <sitecore>
    <layoutService>
      <configurations>
        <config name="sxa-jss">
          <rendering>
            <renderingContentsResolver>
              <!-- Set the desired values for the JSS configuration -->
              <IncludeServerUrlInMediaUrls>false</IncludeServerUrlInMediaUrls>
            </renderingContentsResolver>
          </rendering>
        </config>
      </configurations>
    </layoutService>
  </sitecore>
</configuration>

That resolved my problem: the desired image loaded well.

Hope this helps you as well!

Changing item ID in XM Cloud from item's context menu

Historically, changing the ID of an item in Sitecore was widely believed to be impossible, particularly within the constraints of XM Cloud.

In the XP era, I experimented with a custom package nearly a decade ago that did successfully change item IDs. However, achieving this required considerable effort: decompiling out-of-the-box DLLs, reverse engineering the code, and then rebuilding DLLs to match various Sitecore versions and dependencies. The process was complex and not ideal, dependent on runtime specifics. I managed to make it function, but lots have changed since then.

With XM Cloud, many of those challenges have been eliminated. Notably, the platform now includes Sitecore PowerShell Extensions (SPE) out of the box. This makes it feasible to develop a universal script that can change the ID of any item in the content tree, invoked directly from the context menu without the need for server-level modifications. All it takes is a simple package containing a single Sitecore item.

Why change ID?

1. Database Merges and Migrations

  • Resolving ID Conflicts During Database Merges: When combining multiple Sitecore databases (e.g., from separate instances or acquisitions), duplicate IDs can occur if items were created independently. Changing the ID of conflicting items in one database allows seamless integration without data loss.
  • Content Migration from Legacy Systems: During upgrades or migrations from older Sitecore versions or other CMS platforms, imported items might have IDs that clash with existing ones. Changing IDs ensures uniqueness while preserving content structure.
  • Multi-Environment Synchronization: If dev, staging, and production environments diverge (e.g., due to unsynced TDS/Unicorn serializations), changing IDs in one environment can align them without recreating everything.
  • Cross-Database Item Transfers: When moving items between master, core, or web databases, ID collisions might arise; changing the ID facilitates clean transfers.

2. Integration with External Systems

  • Aligning with External Data Feeds or APIs: If an external system (e.g., a CRM like Salesforce or a data warehouse) references Sitecore items by GUIDs but uses its own fixed IDs, changing Sitecore's item ID to match the external one ensures synchronization without modifying the external system.
  • Third-Party Tool Compatibility: Some integrations (e.g., e-commerce plugins or analytics tools) might require specific ID formats or mappings; changing IDs bridges mismatches.
  • Bi-Directional Sync with Other Platforms: In setups with real-time syncing (e.g., via Sitecore Data Exchange Framework), ID changes might be needed to resolve sync errors caused by GUID mismatches.
  • Federated Identity Management: For systems integrating Sitecore with identity providers, where user-generated content items need ID alignment for security or tracking.

3. Item Recovery and Error Correction

  • Recovering from Accidental Deletions: If a critical item is deleted (and not sent to the Recycle Bin) but referenced extensively, recreating it with a new ID and updating links/indexes can restore functionality faster than manual fixes, especially if the original ID causes reference breakage.
  • Fixing Overwritten or Forgotten Items in Version Control: In team environments using TDS or Unicorn, if an item is overwritten or omitted during serialization, changing its ID can resolve conflicts and reintegrate it without rebuilding the entire project.
  • Correcting Database Corruption or Duplicates: Rare DB errors (e.g., from failed restores) might create duplicate IDs; changing one item's ID eliminates the duplication.
  • Repairing Broken References After Errors: If an item's ID leads to index or link database issues (e.g., due to partial publishes), changing it and triggering a reference update can fix widespread breakage.

4. Development and Testing Scenarios

  • Simulating ID Conflicts in Testing: Developers might change IDs to test merge scripts, index rebuilds, or reference update pipelines in controlled environments.
  • Refactoring Large Content Trees: During site redesigns, changing IDs of template items or folders can help reorganize without disrupting live content, especially if combined with link repairs.
  • Debugging Custom Modules: To isolate issues in custom code that relies on specific IDs (e.g., caching or workflows), temporarily changing an ID can aid troubleshooting.
  • Performance Optimization Testing: Changing IDs to force index refreshes or cache invalidations in load testing, helping identify bottlenecks.

4. Other creative scenarios

  • Proof of authoring: In certain cases, you may want to intentionally leave a trace or proof of authoring in the item's ID so that you can prove the fact that you've done this job at some stage later. Once working on a questionable project, I used this trick to encode my phonу number as the last digits of the site definition item. This does not affect any functionality, but at the same time cannot be just a random coincidence.
  • Easter eggs: In some mostly non-production cases, you may want to have some fun with issuing creative naming IDs. HEX-based codes allow plenty of creativity there. For example, all these are valid HEX numbers: 
    • Four chars long: BEEF, CAFE, BABE, FACE, CODE, FEED, etc.
    • Eight chars long: DEADFACE, C0FFEEED, C0DEC0DE, BEEFC0DE, etc.
    • Twelve char long: DEADC0FFEEED, DEADFADEBEEF, AD1DA5AD1DA5, etc.

 

Key Features of the PowerShell Module

The PowerShell module provided below delivers the following capabilities:

  • Assigns a new ID to any item: Accepts user input for the new ID, supporting not only standard content items but also templates, media, system items, and more.

  • Processes hierarchical structures: Handles items with children, fields, and sections, while maintaining the integrity of Standard Values for templates.

  • Updates derived content items: Sets the correct TemplateID on all derived items, preventing "object reference not set" errors.

  • Moves and patches descendants: Migrates and updates all child sections and fields under the new template as needed.

  • Refreshes all references: Updates any base templates, field sources, or XML fields that reference the old template.

  • Cache management: Flushes Sitecore caches and, optionally, rebuilds the Link Database and triggers a publish.

  • Efficient querying: Utilizes fast queries to ensure every relevant content item is included.

  • Comprehensive edge case handling: Covers all major scenarios and exceptions.

How It Works

Since Sitecore does not provide an API for changing item IDs directly, the script implements a robust workaround:

  1. Duplicate the source item (with all fields) under a new ID using the SPE API, which supports custom ID assignment.

  2. Move all children from the old item to the newly created item.

  3. Update all references in the database to point from the old ID to the new one.

  4. Delete the original item once all dependencies have been switched.

Note: Due to the item deletion step involved, this script cannot be used on items that originate from resource files; only items stored in a database are eligible.

Usage

Download

[Download package: ChangeItemID-1.0.zip]

Dictionary in XM Cloud

Localization is more than translation - it's about delivering a seamless, culturally relevant experience for every visitor. In Sitecore XM Cloud, dictionary items provide a simple, robust way to centralize UI text and labels for all languages, directly accessible in your Next.js frontend. Below, I’ll walk through the practical setup, usage patterns, and expert recommendations based on Sitecore’s official documentation and proven community practices.

What Are Dictionary Items?

Dictionary items in XM Cloud let you store and manage all your UI phrases centrally.
Instead of hardcoding "Read More", "Submit",  or error messages, you create dictionary keys under your site’s /Dictionary item. Each key holds localized values for every language you support.

Why:
  • Centralized: Update once, used everywhere
  • Multi-language: Add a new language, all keys covered
  • Consistent: No more stray hardcoded strings

When to Use Dictionary vs. Fields

Typically, you would use dictionary entries for generic text reused in many places (e.g., button labels like "Submit", form field placeholders, system messages). For content that might be specific to a single page or component (e.g., a marketing tagline, section heading, or lengthy description), consider making it a field in a content item or datasource rather than a shared dictionary entry. This gives content authors more flexibility to customize text in context. 

A hybrid approach can work too, for example, use a dictionary phrase as a fallback but allow an override via a field in the CMS if the editor wants a custom message in one place.

How Dictionary Items Work in XM Cloud

In XM Cloud, dictionary items are stored at: /sitecore/content/{collection}/{site}/Dictionary and is fetched via GraphQL through the Layout/Dictionary service. Essentially, for each page request, a GraphQL query runs to get all dictionary entries under the site’s dictionary path for the given language. The Next.js SDK’s DictionaryServiceFactory handles this, so you typically don’t need to write this query yourself. If curious, the query filters by the dictionary root item path, language, and the Dictionary Entry template, and returns all key-phrase pairs. Because this data is pulled at request-time (or build-time for static pages), the translations are ready by the time React components render. No client-side extra fetch is needed for dictionary data. It’s included in the page HTML/props payload.

Each dictionary item represents a key (e.g., "read"), and under it, each language version holds the translated phrase.

For multi-site or SXA setups, you can create a shared dictionary (often in a Shared site or at the tenant root), and then set up a fallback so multiple sites reuse the same dictionary keys. On the integration side, XM Cloud provides a Dictionary Service API, available via REST or GraphQL, which populates the dictionary data at build or runtime for your Next.js app.

Sitecore’s Next.js SDK (part of Headless JSS) comes pre-configured to utilize dictionary data for localization. Here’s how the pieces fit together:

  • Next.js Internationalization (i18n) Settings. In your Next.js app’s next.config.js, define the locales your site supports and a default locale. These locales should match the languages enabled in Sitecore. For example:
    i18n: {
      locales: ['en', 'fr-CA', 'es'], 
      defaultLocale: 'en'
    }
    This allows Next.js to handle locale-specific routes, such as /fr-ca/page-name for French Canadian. The defaultLocale should correspond to your site’s default language, often pulled from a config setting of package.json in the Sitecore starters. If you add a new language to Sitecore, update this config and redeploy the app so Next.js knows about the new locale.
  • Page Props and Dictionary Data. The Sitecore Next.js SDK fetches dictionary entries for each page request (or build) and injects them into the page properties. Specifically, the JSS Page Props Factory (a plugin in the Next.js sample app) uses the Dictionary Service to retrieve all dictionary phrases for the current site/language, and includes them as a dictionary object in pageProps. This happens server-side, either at build time for static generation or on the server for SSR, so that the dictionary is ready by the time the React components render.
  • I18n Provider. The Next.js app uses the next-localization library (a lightweight i18n solution) to provide translations. In the app’s custom _app.tsx, an <I18nProvider> is configured to wrap the page component, initialized with the locale and the dictionary data from Sitecore. For example:
    // In _app.tsx
    import { I18nProvider } from 'next-localization';
    function App({ Component, pageProps }) {
      const { dictionary, locale, ...rest } = pageProps;
      return (
        <I18nProvider lngDict={dictionary} locale={locale}>
          <Component {...rest} />
        </I18nProvider>
      );
    }
    Here, pageProps.dictionary contains all key–phrase pairs for the current locale, and pageProps.locale is the language code. The I18nProvider makes a translation function available to all components via React context.
  • Using the Translation Hook. Inside your React components, you use the useI18n() hook (from next-localization) to access the localization context. See the below paragraph for the exact dictionary consumption code sample.

The Single useTranslation Hook Pattern (with Fallback Defaults)

To keep things tidy, many teams prefer a single useTranslations hook. Here’s a pattern that ensures you never show a blank label, even if the dictionary entry is missing, by supplying a fallback default:

import { useI18n } from 'next-localization';

export function useTranslation() {
  const i18n = useI18n();
  // Utility: always show a fallback if dictionary value is missing
  const getTranslation = (key, fallback) => i18n?.t(key) || fallback;

  return {
    readLabel: getTranslation('read', 'Read'),
    submitLabel: getTranslation('submit', 'Submit'),
    // Add more keys as needed
  };
}

// Usage in a component
const translations = useTranslation();

return (
  <div>
    <button>{translations.readLabel}</button>
  </div>
);

Why this is better:

  • No undefined or blank UI if a dictionary key is missing
  • All translations accessible via one hook - simple, scalable
  • Easy to add more keys as your UI grows

Using Shared SXA Dictionaries & Fallback Domains

If you’re using SXA or multiple sites, you can set up a shared dictionary and configure fallback so every site can leverage the same keys.

Setup Steps:

  1. Create a Shared Dictionary. Place it in a shared location, such as /sitecore/content/{collection}/Shared/Dictionary
  2. Set Dictionary Domain. In Sitecore, under your site’s Dictionary item, set the Dictionary Domain field to the shared dictionary’s path. (Official Doc)
  3. Configure Fallback. On your site’s dictionary, enable "fallback domain" to point to the shared dictionary.

In your Next.js app, the Dictionary Service will automatically merge keys from both the site and shared domains. 

Storybook: Mocking Dictionaries for Isolated UI Development

When using Storybook, components expecting dictionary values may break outside the app context. To avoid this, you can mock the next-localization context in .storybook/preview.tsx:

import { I18nProvider } from 'next-localization';

const mockDictionary = {
  read: 'Read',
  submit: 'Submit',
  // Add other keys as needed
};

export const decorators = [
  (Story) => (
    <I18nProvider lngDict={mockDictionary} locale="en">
      <Story />
    </I18nProvider>
  ),
];
Why this matters:
  • Ensures all components render correctly in isolation
  • Lets designers and QA see accurate UI text in stories
  • Helps catch missing dictionary keys before production

Troubleshooting Dictionary Issues

If you encounter an issue where dictionary translations are not appearing in the Next.js app, it is highly likely that it happens because of one of the very frequently missed things. Consider the following checks:
  • Verify Config and App Name. Make sure the Next.js app’s configured site name (app name) matches the Sitecore site name. This is typically set in your app’s package.json (config.appName) or .env and used by the JSS config. If it mismatches Sitecore’s expected name, the dictionary service might look in the wrong place. In XM Cloud projects, the app name is usually the same as your site name in Sitecore.
  • Check Dictionary Domain Path. As noted, ensure the site’s dictionary domain/path settings in Sitecore are correct. If the Dictionary item was renamed or moved, update the site settings or the config patch so the head application knows where to fetch phrases.
  • Publish Content. Confirm that the dictionary items are published to the target environment (Edge). In a development scenario, if using Connected Mode, ensure the dictionary items exist in the expected database. After editing dictionary entries in the Pages or Content Editor interface, run a publish. Unpublished items will not appear in the GraphQL results.
  • For preview mode issues, rebuild the search index. On XM Cloud, indexes are auto-rebuilt on deploy, but if you import items or restore content, an index rebuild might be necessary.
  • Ensure a valid site root is defined. If you see errors like “Valid value for rootItemId not provided” in logs or GraphQL responses, it indicates the dictionary GraphQL service couldn’t auto-resolve the site’s root. Ensure your site template is correct; if fixing it, you can remove any hardcoded rootItemId overrides you may have added during debugging.

Bonus: Dictionary Migration and Conversion SPE Script

The below script copies dictionary items from an old XP database (referred to as old) to the master database of the locally-run XM Cloud environment and also converts Dictionary entries from a custom format of the Habitat website (which many folks have used as a starter kit, regardless) to the XM Cloud format, which is hard-defined by a dictionary template coming from the items resource file:
param(
    [string]$SourceDatabase = "old",
    [string]$SourcePath = "/sitecore/content/Habitat/Global/Dictionary",
    [string]$TargetDatabase = "master",
    [string]$TargetPath = "/sitecore/content/Zont/Habitat/Dictionary"
)

$srcFolderTemplate = "{98E4BDC6-9B43-4EB2-BAA3-D4303C35852E}"
$srcEntryTemplate = "{EC4DD3F2-590D-404B-9189-2A12679749CC}"
$srcPhraseField = "{DDACDD55-5B08-405F-9E58-04F09AED640A}"

$trgFolderTemplate = "{267D9AC7-5D85-4E9D-AF89-99AB296CC218}"
$trgEntryTemplate = "{6D1CD897-1936-4A3A-A511-289A94C2A7B1}"
$trgKeyField = "{580C75A8-C01A-4580-83CB-987776CEB3AF}"
$trgPhraseField = "{2BA3454A-9A9C-4CDF-A9F8-107FD484EB6E}"

function Copy-DictionaryItemsRecursive {
    param(
        [Sitecore.Data.Items.Item]$SrcParent,
        [Sitecore.Data.Items.Item]$TrgParent,
        [Sitecore.Data.Database]$SrcDB,
        [Sitecore.Data.Database]$TrgDB,
        [int]$Depth = 0
    )

    $indent = (" " * ($Depth * 2))
    $children = $SrcParent.GetChildren()
    Write-Output "$indent- Processing source: $($SrcParent.Paths.FullPath) | Child count: $($children.Count)"

    foreach ($child in $children) {
        Write-Output "$indent  - Processing child: $($child.Paths.FullPath) | TemplateID: $($child.TemplateID) | Name: $($child.Name)"
        $templateId = $child.TemplateID.ToString()
        $newItemName = $child.Name
        $newItem = $null

        try {
            # Check if this item already exists by name under TrgParent
            $existingByName = $TrgParent.Children | Where-Object { $_.Name -eq $newItemName }
            if ($existingByName) {
                Write-Output "$indent    - Using existing target item (by name): $($existingByName.Paths.FullPath)"
                $newItem = $existingByName
            } else {
                # Prepare template item
                $templateItem = $null
                if ($templateId -eq $srcFolderTemplate) {
                    $templateItem = $TrgDB.GetTemplate($trgFolderTemplate)
                    Write-Output "$indent    - Using target template (folder): $($templateItem -ne $null)"
                } elseif ($templateId -eq $srcEntryTemplate) {
                    $templateItem = $TrgDB.GetTemplate($trgEntryTemplate)
                    Write-Output "$indent    - Using target template (entry): $($templateItem -ne $null)"
                }
                if (-not $templateItem) { throw "$indent    - ERROR: Target template not found for $newItemName" }

                # Attempt to create under the current TrgParent
                Write-Output "$indent    - Attempting to Add '$newItemName' under $($TrgParent.Paths.FullPath)"
                $newItem = $TrgParent.Add($newItemName, $templateItem)
                if (-not $newItem) { throw "$indent    - ERROR: Failed to add item $newItemName under $($TrgParent.Paths.FullPath)" }
                Write-Output "$indent    - Created item: $($newItem.Paths.FullPath) [ID: $($newItem.ID)]"
            }

            # Recurse or fill entry fields
            if ($templateId -eq $srcFolderTemplate) {
                Write-Output "$indent    - Recursing into folder: $($child.Paths.FullPath)"
                Copy-DictionaryItemsRecursive -SrcParent $child -TrgParent $newItem -SrcDB $SrcDB -TrgDB $TrgDB -Depth ($Depth + 1)
            } elseif ($templateId -eq $srcEntryTemplate) {
                foreach ($lang in $child.Languages) {
                    $srcItemLang = $SrcDB.GetItem($child.ID, $lang)
                    if (-not $srcItemLang) { throw "$indent    - ERROR: Source item language $lang missing for $($child.Paths.FullPath)" }
                    $trgItemLang = $TrgDB.GetItem($newItem.ID, $lang)
                    if (-not $trgItemLang) {
                        $trgItemLang = $newItem.Versions.AddVersion($lang)
                        if (-not $trgItemLang) { throw "$indent    - ERROR: Failed to add language version $lang to $($newItem.Paths.FullPath)" }
                    }
                    $phraseValue = $srcItemLang.Fields[$srcPhraseField].Value
                    $trgItemLang.Editing.BeginEdit()
                    $trgItemLang.Fields[$trgKeyField].Value = $newItemName
                    $trgItemLang.Fields[$trgPhraseField].Value = $phraseValue
                    $trgItemLang.Editing.EndEdit()
                    Write-Output "$indent    - Populated entry ($lang): $($newItem.Paths.FullPath) [ID: $($newItem.ID)]"
                }
            } else {
                Write-Output "$indent    - Skipping unsupported template: $($child.Name) [$templateId]"
            }
        } catch {
            Write-Output "$indent    - ERROR: $($_.Exception.Message) at item $($child.Paths.FullPath)"
            throw
        }
    }
}

try {
    $srcDB = Get-Database $SourceDatabase
    $trgDB = Get-Database $TargetDatabase
    $srcParent = $srcDB.GetItem($SourcePath)
    $trgParent = $trgDB.GetItem($TargetPath)
    Write-Output "Start: Copying dictionary items from $SourcePath [$SourceDatabase] to $TargetPath [$TargetDatabase] ..."
    if (-not $srcParent) { throw "Source parent item not found: $SourcePath in DB: $SourceDatabase" }
    if (-not $trgParent) { throw "Target parent item not found: $TargetPath in DB: $TargetDatabase" }

    Copy-DictionaryItemsRecursive -SrcParent $srcParent -TrgParent $trgParent -SrcDB $srcDB -TrgDB $trgDB -Depth 0
    Write-Output "Dictionary copy completed successfully."
} catch {
    Write-Output "SCRIPT ERROR: $($_.Exception.Message)"
    exit 1
}

Summary & Best Practices

  • Use a single, type-safe useTranslation hook for clarity and safety
  • Always supply fallback defaults to prevent UI errors
  • Prefer shared dictionaries for multi-site projects? Configure fallback domains in Sitecore for reusability
  • Mock dictionary data in Storybook to maintain robust component previews

Hope you find this post helpful!

Using these XM Cloud hotkeys can significantly boost your productivity

Do you use hotkeys? 

I am pretty sure most of you intuitively press Ctrl + S in order to save the changes in Content Editor. Highly likely, you also do any of these below:

  • Del -for deleting an item, and all of its children
  • F2 - rename an item

However, there are plenty of other meaningful shortcuts for you to use:

  • F9 - publish site dialog
  • Ctrl + D - duplicates an item, one of my favorite ever.
  • Ctrl + Win - opens a Sitecore "Start" menu (however, that does not work in 100% of cases)
  • F7 - Validation Results
  • Left / Right keyboard arrows allow expanding and collapsing items' nodes

View togglers:

  • Ctrl + Shift + Alt + R: Toggle raw values. This is one of my best savers when I need to grab an ID for a lookup from a reference field.
  • Ctrl + Shift + Alt + T: Toggle the display of standard fields, which helps improve Content Editor performance when off
  • Ctrl + Shift + Alt + L: Toggle the protection for a selected item.

Content Editor Tabs:

IMG

They typically work as Alt + <first_letter_of_a_tab>:

  • Alt + H: Home
  • Alt + N: Navigate
  • Alt + R: Review
  • Alt + P: Publish
  • Alt + V: Versions
  • Alt + C: Configure
  • Alt + S: Security

 ... but since several tabs start from the same character, less straightforward hotkeys are used:

  • Alt + I: View

 

Predictive search

In Content Editor, hit Ctrl + / and you'll see the bottom right search area activate, waiting for your input. Type in at least two characters for it to progressively show the results, as you keep typing.

 

Mouse Gestures

Dragging and item with Ctrl button held creates a copy of that item right at the location where you release the button.

 

How do I know these shortcuts?

There is a master shortcut Alt + F1 that brings up helpful labels exposing the hotkeys for the specific functions on a Ribbon:

Hope this helps you staying more productive!

XM Cloud productivity tip: Use Cloud Portal Auto Login extension for Chrome

It may be that you're as annoyed as I am by the way certain things are implemented. Imagine you spin up local XM Cloud in containers, type Up.ps1, hit enter, and go away to make some coffee. It typically takes some time while Docker gets updated images and performs the rest of its business, but right in the middle, it gets interrupted by this:

The only purpose of this screen is for you to confirm, simply clicking the "CONFIRM" button, and then keep waiting for approximately the same amount of time you already spent. It won't progress until you actually press this button.

That isn't nice, what a time waster, I thought, and started thinking about the ways of automating clicking the confirmation button, so that the entire Docker start process goes uninterrupted. Created a Chrome extension that appeared to be the easiest one, so there I went.

Then I thought it would be a good idea also to automate submissions to the other screens when you process to the portal, namely this one:

The question comes next question is how to safely keep the email identifier separately from the extension. Browser local storage seems to be a good compromise, so let's go that way. Once users come to the portal for the first time, they enter their email ID as normal. At the time of submission, the extension intercepts the button click and stores the value encrypted. On the next visit to this screen, the value is decrypted and gets automatically submitted into the field by the extension.

I use basic base64 encryption, but with an additional step of character rotation (also known as the Caesar Cipher). This prevents a stranger from a straightforward decipherment of the value taken directly from the local storage. Email ID isn't a very sensitive piece of information, so I am not going wild with protecting its value.

Finally, when the user deliberately logs out by using a drop-down menu from the top right corner, the extension removes the value from the browser's storage. This is done purposely, for example, if one logs out, they usually do that in order to log in as the other user, or maybe they just want out of the system - in any case, we must ensure no further automatic logins persist, and that is exactly what happens.

Currently, it is under moderation in the Chrome store, so you may load this archive by this link and progress with the "Load unpacked" extension, as shown below:

Source code is available here: https://github.com/MartinMiles/cloud-portal-auto-login

Hope you like that one. If you have any comments or thoughts, reach out to me!