Shipping a Prebuilt Editing Host to SitecoreAI When the Platform Can't Build It
Back to home

Shipping a Prebuilt Editing Host to SitecoreAI When the Platform Can't Build It

Miguel Minoldo's picture
Miguel Minoldo

SitecoreAI builds your editing host for you. You add a renderingHosts entry to xmcloud.build.json, point it at your head app, deploy, and the platform builds and runs the connected codebase natively. That is the happy path, and for most projects it is the right one. You do not own the infrastructure, you do not maintain a Dockerfile, and the editing host stays in lockstep with the authoring environment.

It stopped being the right path for us the day our component library moved behind the client's VPN.

The library lived in a private npm registry (JFrog Artifactory) that was only reachable from inside the client's network. Our own CI could reach it. SitecoreAI's build infrastructure could not, and it had no reason to be able to. Getting a firewall exception for Sitecore's build egress would have meant a security review on the client side, and that is not a fast conversation in a regulated enterprise. It was not a technical fix I controlled, so I stopped treating it as one.

The obvious escape hatch is an external editing host. Deploy the app to your own provider, wire the rendering host item to its endpoints, and you never touch SitecoreAI's build at all. We were already running the public rendering host on GCP Cloud Run (that is the Cloud Run series), so we had the muscle memory. But standing up a second Cloud Run service, a second image, a second set of secrets, and a second thing to monitor, purely to serve editing traffic, was more infrastructure than the problem deserved. I did not want to pay for a whole external host to work around one npm registry.

So the requirement landed in an awkward place. Keep the internal editing host, native to SitecoreAI, but stop the platform from building it. Ship it a server that our own pipeline already built, and have the platform just run it.

This post is how that actually works, and the walls we hit getting there. None of the platform behavior below is documented anywhere I could find. It was all determined empirically, by instrumenting the deploy and reading the installed @sitecore-content-sdk/nextjs and next source directly.

What "let SitecoreAI build it" actually does

Start with the official model, because the fix only makes sense against it. SitecoreAI's own docs describe it plainly: when you deploy an editing host, Sitecore builds and deploys the connected codebase, and the rendering host items under /sitecore/system/Settings/Services/Rendering Hosts are autogenerated from xmcloud.build.json during the authoring deployment (editing host types).

"Builds the connected codebase" is the part that hurts when your codebase pulls from a registry the builder cannot reach. So the goal is to make that build a no-op, and hand the platform a finished server instead.

Three things about the platform's upload and artifact processing turned out to matter, and none of them are in the docs:

  1. It always runs npm install. Regardless of what buildCommand is set to, or whether it is set at all, npm install runs. You cannot skip it from the build config.
  2. It silently drops anything dot-prefixed. Any file or directory whose name starts with a dot (.next, .env), at any depth, does not survive the upload into the build and runtime environment. This happens independently of .gitignore. I confirmed it by listing files on both sides of the upload.
  3. It sets HOSTNAME to the pod's own hostname. Next.js's standalone server binds to process.env.HOSTNAME when it is set, and 0.0.0.0 otherwise. The platform sets HOSTNAME to the pod hostname, so the server binds only to that specific address.

Every wall below is a consequence of one of those three.

Here is the shape of what we were trying to do, against what the platform does by default.

The default path blocks the moment the build needs a package the platform's builder cannot reach. Our path builds in CI and hands the platform a finished server.
Click to expand
The default path blocks the moment the build needs a package the platform's builder cannot reach. Our path builds in CI and hands the platform a finished server.

Wall 1: buildCommand changes don't stop the rebuild

What we tried: remove buildCommand, set a runCommand (or startCommand on older configs) against the uploaded, pre-built .next/standalone output. If Jenkins already built the app, the platform should just run it.

What happened: partial success. The full next build compile step was genuinely skipped. But npm install ran anyway, unconditionally, visible in the build log. The private package @acme/component-library was still being requested, and the builder still could not reach the registry to resolve it.

The fix is not to skip the install. You cannot. The fix is to make the install have nothing it cannot resolve. Two moves, together:

  • In CI, resolve the private scope through a scoped registry before building: npm config set "@acme:registry" "$PRIVATE_NPM_REGISTRY", then a plain npm ci. Your package.json just declares a normal version string.
  • Right before you hand the build to the platform, strip every package under your private scope out of the dependencies and devDependencies of the package.json that ships with the upload.

That second move sounds reckless until you remember how Node resolves modules. Node loads from node_modules on disk at runtime, not from package.json. And plain npm install (unlike npm ci) does not prune packages that are already present but no longer declared. So the already-built node_modules, private packages included, travels with the upload untouched, the platform's npm install never goes looking for @acme/*, and the app runs identically. The manifest entry was the only thing pointing the builder at a registry it could not reach. Remove the entry, remove the problem.

I do this with a small node -e script that matches by scope prefix rather than an explicit package list, so it keeps working as the library grows:

1const fs = require('fs');
2const scopes = (process.env.PRIVATE_NPM_SCOPES || '').split(' ').filter(Boolean);
3const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
4for (const depType of ['dependencies', 'devDependencies']) {
5 if (!pkg[depType]) continue;
6 for (const name of Object.keys(pkg[depType])) {
7 if (scopes.some((scope) => name.startsWith(scope + '/'))) {
8 delete pkg[depType][name];
9 }
10 }
11}
12fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));

Wall 2: the build output never arrives

What we tried next: a genuine no-op buildCommand. Instead of omitting it, point it at a script that just checks the prebuilt files exist (verify-prebuilt). The theory was that the platform's harvest of .next/standalone into its deployable artifact might be tied to buildCommand running at all.

What happened: verify-prebuilt reported .next/standalone/server.js missing. That was informative. It proved the file never reached the platform's build environment in the first place, regardless of buildCommand. The harvest theory was wrong. The problem was upstream, in the upload itself.

I added two diagnostics rather than keep guessing: a recursive file listing printed by verify-prebuilt, and a snapshot on the CI side of exactly what was about to be uploaded. Comparing them was unambiguous. .next/ was present in what Jenkins sent and absent from what the platform saw.

.next is dot-prefixed. So is .env. Both got dropped.

The fix: stop relying on dot-prefixed paths surviving. Next.js lets you relocate the build directory, so build under a plain name and move the standalone output into a plain directory before upload.

In next.config.ts:

distDir: process.env.NEXTJS_DIST_DIR || '.next',
output: 'standalone',

In the build stage, set NEXTJS_DIST_DIR to something without a leading dot (next-build), then copy the standalone server and static assets into a prebuilt-server/ directory. Both moves are required. The distDir rename gets the build output out of .next, and the top-level relocation gets it out of any other dot-prefixed parent.

Plain paths survive. Dot-prefixed paths do not, at any depth, independent of .gitignore.
Click to expand
Plain paths survive. Dot-prefixed paths do not, at any depth, independent of .gitignore.

Wall 3: "Could not find a production build in the './.next' directory"

Progress. With the output relocated, the server was found and started this time. The Next.js banner appeared. Then it failed to serve, with Could not find a production build in the './.next' directory.

What's happening: Next.js's standalone server.js resolves its build directory relative to process.cwd(), not relative to the script's own location. The start script ran node prebuilt-server/server.js from the parent directory, so the server looked for ./.next in the wrong place, and under the wrong name.

Fix, first half: cd into the directory that holds server.js before running it, so process.cwd() matches where the build output lives.

Same error persisted. Fix, second half: the server was now in the right directory but still looking for the default name, .next. NEXTJS_DIST_DIR had been set at build time only. The running server had no idea the output had been renamed to next-build. Set it at run time too:

cd prebuilt-server && NEXTJS_DIST_DIR=next-build node server.js

The lesson here is small and generalizes: distDir is a build-time and a run-time concern. If you rename it, you rename it in both phases.

Wall 4: the runtime secrets get stripped too

With the server running, Page Builder failed with TypeError: Failed to parse URL from /. The trail led to several environment variables that the Sitecore config needs at runtime, not just at build time (SITECORE_EDGE_CONTEXT_ID, SITECORE_EDITING_SECRET). Next.js's own docs call this out: a output: 'standalone' deployment needs a manually copied .env file for exactly this.

So we wrote one into prebuilt-server/.env. It got stripped. Of course it did. It is dot-prefixed, and Wall 2 already told us how that ends. The upload processing is not gitignore-aware. It drops the dot-prefixed path unconditionally.

Fix: deliver the runtime values through a plain, non-dot-prefixed .js wrapper instead of a .env file. The wrapper sets process.env.* and then requires the real server. It survives the upload because its name does not start with a dot.

1// prebuilt-server/runtime-secrets.js (generated in CI)
2process.env.SITECORE_EDGE_CONTEXT_ID = "<value>";
3process.env.SITECORE_EDITING_SECRET = "<value>";
4process.env.SITECORE = "1";
5process.env.HOSTNAME = "0.0.0.0";
6require('./server.js');

The start command runs this wrapper instead of server.js directly. Generate it, do not commit it, and embed each value with JSON.stringify() rather than string interpolation. Secrets contain quotes, dollar signs, and backslashes, and JSON.stringify keeps any of that from breaking the generated file. I tested that against deliberately hostile values.

Two of those four assignments (SITECORE=1 and HOSTNAME=0.0.0.0) are not about secrets at all. They were the last wall, and the hardest one.

Wall 5: Page Builder still fails, identically, after everything above

This is the one that cost the most time, because the error never changed while the cause moved. Same TypeError: Failed to parse URL from /, through several fixes that each looked complete.

I ruled things out in order. An empty sitecoreEdgePlatformHostname in the config looked like a plausible culprit, since it feeds the edge URL. I traced it through the SDK source (resolveEdgeUrl, normalizeEnvValue in @sitecore-content-sdk/core) and confirmed an empty string is explicitly treated as "not provided" and falls back to the SDK default. Not the cause.

Static analysis was exhausted and the error was identical across fixes that should have changed something. So I stopped reading and started intercepting. I wrapped the global URL constructor in the runtime wrapper to log the exact arguments and the full, uncollapsed stack before rethrowing. Next.js collapses framework frames as ignore-listed in its own console formatting, which was hiding the one frame that mattered.

The captured stack showed the crash was not where I assumed. It was not in resolveServerUrl's new URL(route, base). It was in the editing route handler's catch block, in a Response.redirect(route) fallback, called when the handler's primary attempt to fetch the page content fails. Response.redirect() requires an absolute URL. Passing it a bare / throws exactly this error. That is arguably a bug in the SDK's fallback path, but it was a symptom. The real question was why the primary fetch failed.

The primary fetch is an internal self-request. To render the actual page HTML for the editing response, the handler fetches back into the running server at http://localhost:3000. That loopback fallback is enabled by SITECORE=1 (the SDK's resolveServerUrl returns http://localhost:3000 "in xmc deployment", and PreviewProxy no-ops entirely without it, its own comment says it only supports internal editing hosts deployed on SitecoreAI).

The self-fetch had nothing to connect to. Checking next/dist/build/utils.js directly: const hostname = process.env.HOSTNAME || '0.0.0.0'. The platform sets HOSTNAME to the pod's own hostname, which had been sitting in the startup banner the whole time (web-client-6b9d4f-x2p8q:3000, not localhost:3000). The standalone server was binding only to that specific address. Reachable externally, unreachable over loopback, and loopback is exactly what the internal self-fetch uses.

Fix: set HOSTNAME=0.0.0.0 in the runtime wrapper, forcing the server to bind to all interfaces. External traffic still arrives on the pod's normal address, and the loopback self-fetch now has something to connect to. SITECORE=1 and HOSTNAME=0.0.0.0 are siblings here, not alternatives. You need both. One turns the self-fetch on, the other gives it a destination.

The same error text on both branches. The real fork is whether the server is bound where its own loopback fetch can reach it.
Click to expand
The same error text on both branches. The real fork is whether the server is bound where its own loopback fetch can reach it.

The one that was hiding in plain sight

Worth its own mention, because it wasted a cycle before Wall 5 even started. Even with the runtime wrapper delivering the right env vars, Page Builder failed identically. A search through the app (later re-verified by reading the installed SDK source) found sitecore.config.ts was export default defineConfig({}). Completely empty. git log showed it had shipped that way since the first scaffold commit and had never been touched.

SitecoreClient, the editing route handlers, and the multisite and personalize proxies all read that global config object. With it empty, edge.edgeUrl, edge.contextId, and editingSecret were all undefined no matter what environment variables were set. The env plumbing was correct and irrelevant, because nothing was reading it.

Fix: populate sitecore.config.ts from the sitecore.config.ts.example template, wired to the same env vars already being plumbed through. If your editing host throws config-shaped errors that survive every env fix you throw at them, check that the config object is not an empty stub before you go any deeper. An empty default config produces runtime errors that look unrelated to their actual cause.

The final, working setup

Consolidated, so you do not have to reconstruct it from the walls.

next.config.ts

1distDir: process.env.NEXTJS_DIST_DIR || '.next',
2output: 'standalone',
3
4xmcloud.build.json
5
6{
7 "renderingHosts": {
8 "web-client": {
9 "path": "apps/web-client",
10 "nodeVersion": "24.14.1",
11 "enabled": true,
12 "type": "sxa",
13 "buildCommand": "verify-prebuilt",
14 "runCommand": "start:standalone"
15 }
16 }
17}

One accuracy note, because I hit the version drift myself. The current SitecoreAI build configuration reference lists startCommand as deprecated and ignored, and directs you to runCommand. Older xmcloud.build.json files (and plenty of community examples) still carry startCommand. Use runCommand for the launch script on a current environment. If a startCommand is lingering in your config, treat it as ignored and move the value across. The empirical facts of this post (the always-on npm install, the dot-file strip, the HOSTNAME binding) do not depend on which property carries your launch script.

apps/web-client/package.json (relevant scripts)

json
1{
2 "scripts": {
3 "start:standalone": "cd prebuilt-server && NEXTJS_DIST_DIR=next-build node runtime-secrets.js",
4 "verify-prebuilt": "test -f prebuilt-server/server.js && test -f prebuilt-server/runtime-secrets.js && echo 'Found pre-built server, skipping rebuild' || (echo 'ERROR: prebuilt-server files missing, was this uploaded without running the build first?' && exit 1)"
5 }
6}

verify-prebuilt earns its place. npm install runs no matter what, so buildCommand cannot be omitted, but it does not have to do real work. A loud existence check is a legitimate ongoing safety net. If someone uploads without running the pipeline first, it fails immediately with a clear message instead of failing later in a way that looks like a platform problem.

The build stage (any CI, shown as shell)

1set -eu
2
3# Resolve the private scope from a registry your CI can reach.
4for scope in $PRIVATE_NPM_SCOPES; do
5 npm config set "${scope}:registry" "$PRIVATE_NPM_REGISTRY"
6done
7
8npm ci
9NEXTJS_DIST_DIR=next-build npm run build
10
11# Relocate the standalone output into a non-dot-prefixed directory.
12mkdir -p prebuilt-server
13cp -r next-build/standalone/. prebuilt-server/
14mkdir -p prebuilt-server/next-build
15cp -r next-build/static prebuilt-server/next-build/static
16
17# Write the runtime wrapper (a plain .js, because .env won't survive the upload).
18node -e "
19 const fs = require('fs');
20 const line = (k, v) => 'process.env.' + k + ' = ' + JSON.stringify(v || '') + ';';
21 fs.writeFileSync('prebuilt-server/runtime-secrets.js', [
22 line('SITECORE_EDGE_CONTEXT_ID', process.env.SITECORE_EDGE_CONTEXT_ID),
23 line('SITECORE_EDITING_SECRET', process.env.SITECORE_EDITING_SECRET),
24 line('SITECORE', '1'),
25 line('HOSTNAME', '0.0.0.0'),
26 \"require('./server.js');\",
27 ].join('\\n') + '\\n');
28"
29
30# Strip the private scope from the uploaded package.json (node_modules travels regardless).
31node -e "
32 const fs = require('fs');
33 const scopes = (process.env.PRIVATE_NPM_SCOPES || '').split(' ').filter(Boolean);
34 const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
35 for (const t of ['dependencies', 'devDependencies']) {
36 if (!pkg[t]) continue;
37 for (const n of Object.keys(pkg[t])) {
38 if (scopes.some((s) => n.startsWith(s + '/'))) delete pkg[t][n];
39 }
40 }
41 fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
42"

Feed SITECORE_EDGE_CONTEXT_ID and SITECORE_EDITING_SECRET from your secret store into that stage. Both need to exist at runtime, not just at build time, which is the entire reason the wrapper exists.

Key gotchas (quick reference)

Symptom

Cause

Fix

buildCommand changes have no effect on whether the platform rebuilds node_modules

The platform always runs npm install, regardless of buildCommand

Make private packages resolvable in your own build via a scoped registry, then strip the scope from the uploaded package.json

A file that should be in the artifact is missing (.next, .env, anything dot-prefixed)

Upload processing drops dot-prefixed paths unconditionally, at any depth, independent of .gitignore

Rename distDir, relocate output to a plain directory, use a .js wrapper instead of .env

Could not find a production build in the './.next' directory after relocating the build

Standalone server.js resolves its build dir from process.cwd(), and NEXTJS_DIST_DIR was set at build time only

cd into the server.js directory before running, and set NEXTJS_DIST_DIR at run time too

TypeError: Failed to parse URL from / in Page Builder, while plain pages render

Missing SITECORE=1 (disables the loopback fallback) and/or HOSTNAME=0.0.0.0 (server unreachable from its own self-fetch)

Set both in the runtime wrapper

Config-shaped errors that survive every env-var fix

sitecore.config.ts is an empty defineConfig({}) stub, so nothing reads your env vars

Populate it from sitecore.config.ts.example, wired to the same env vars

What This Actually Teaches You

The platform's build is not a build step you can turn off. It is a pipeline you feed. npm install always runs, the upload processing rewrites what you send it, and the runtime sets its own environment. You cannot opt out of any of that from xmcloud.build.json. You work with it: strip what the install should not look for, rename what the upload would drop, override what the runtime sets wrong.

Dot-prefixed means gone. This is the single most useful sentence in this post. .next, .env, anything at any depth. It is not gitignore-based and it is not documented. If a file has to survive the upload, its name cannot start with a dot. Once you internalize that, Walls 2 and 4 stop being mysteries.

The error text lies about its own location. Failed to parse URL from / pointed at a URL construction that was working fine. The real failure was a loopback fetch two layers down, surfaced only because a fallback path had its own bug. When an error survives fixes that should have changed it, stop reading source and start intercepting the runtime. Wrapping the URL constructor to capture the uncollapsed stack found in one run what static analysis had missed across several.

An empty config fails louder than a missing one. A stub defineConfig({}) does not error at startup. It quietly returns undefined for everything downstream, and those undefineds throw far away from the actual cause. Check for empty scaffolding early.

"Ship the artifact" beats "grant the access" when the access is not yours to grant. The whole approach exists because a firewall exception was a slow, cross-organization conversation and standing up external infrastructure was overkill. The node_modules directory already had everything the app needed. It just had to travel, and the manifest had to stop pointing at a registry no one on the build side could reach.

The Bottom Line

The native editing host is the right default, right up until your build has a dependency the platform's builder cannot resolve, and standing up an external host costs more than the problem is worth. That is a narrower situation than it sounds, VPN-gated private registries in regulated enterprises are common, and it puts you in a spot the docs do not cover.

The fix is not clever, it is just specific. Build the standalone server yourself. Rename anything dot-prefixed out of the way. Ship node_modules with the upload and strip the private scope from the manifest so the platform's install has nothing to chase. Deliver runtime values through a .js wrapper, not a .env. And bind the server to 0.0.0.0 so it can answer its own internal fetch. Six moves, each obvious in hindsight, none of them findable in advance.

The hard part was never any single fix. It was that the platform does three undocumented things to your upload, and each one produces an error that looks like something else.

What's Next

The editing host and the public rendering host now build once in the same pipeline and share the result, which killed a version-drift bug where two build paths pinned the component library independently. The next thing I want to write up is that shared build stage in full, because "build once, deploy to two very different targets" (a native SitecoreAI editing host and a Cloud Run service) has its own set of sharp edges around what each target strips, sets, and expects. That connects straight back to the Cloud Run series, and it is where this work actually lives.

References

Official Sitecore documentation:

Next.js:

From my own blog: