Back to blog

Visual Builder without Next.js, Part 2: The Routing Decision Travels, the Engine Contract Doubles

One content tree, two engines, one edge: a strangler for Astro and Next.js. The same routing decision, once in a Next.js proxy and once in a Cloudflare Worker, measured against a local CMS 13 with the checks from part 1.

Series: Part 1: What Astro Has to Rebuild on CMS 13 PaaS · Part 2: The Routing Decision Travels, the Engine Contract Doubles

Status: built and measured in November 2026 against a local Alloy-based CMS 13.5.0 with Optimizely Graph, @optimizely/cms-sdk 3.1.0-beta.0 in both engines, Astro 7.4.2, Next.js 16.4.1 and then 16.4.5, and a Cloudflare Worker under wrangler 4.157.0, with an orange-to-orange rehearsal across two Cloudflare accounts of my own. Nothing here ran on DXP; the limits of the experiment are summarised before the verdict and listed in full in the appendix. Everything is either measured, read in the documentation and the source, or a design choice; where it matters, the text says which, and where I could only form a working understanding, the text says that too. The code, the checks and the raw evidence are in the companion repository, pino-labs/astro-visual-builder-cms13, branch part-2-strangler.

The question

Part 1 showed that Astro can render Visual Builder content on CMS 13 PaaS and listed what has to be rebuilt without the Next.js integration. The question after that is whether Astro could replace Next.js gradually, one page type at a time, without a second CMS application or a second content model, so that a site can move instead of switching over on a weekend.

To answer it I ran one CMS 13 application and one content model against two rendering engines, with a routing decision written independently of both frameworks, first in a Next.js proxy and then in a Cloudflare Worker, choosing the engine page type by page type.

The routing worked. On this setup the same module made the same decisions in both hosts, and preview reached the engine it selected. Invalidation needed a different contract: the Worker forwarded each webhook event to both engines, because either could still hold a cached copy. I moved the article page type from Next.js to Astro and back under scripted traffic, in three runs, with one 502 before the first switch, while Next.js still owned the type. The experiment also drew a boundary: the routing decision was portable, and the integration around it was not. Each engine has to satisfy the integration contract from part 1 on its own, a component registry, a preview route, a request scope and a webhook handler, even where parts of the implementation can be shared, as the webhook logic is here. The edge, in turn, needs Graph to answer for every page whose type it has not cached, and a single bad path in the Worker made it serve another site’s page.

This article examines that boundary, the failures found while testing it, and what remains to be validated before DXP comes into the picture. The detailed results are collected in a table before “How I tested it”.

The application resource in the CMS 13 REST API has a key, a display name, a type, an entry point, hosts, preview-token settings and preview URL formats, and nothing that names a rendering engine; Graph stores the application’s host in _metadata.url.base for every page. No field says “this page type is rendered by Astro” for public routing. So in this design the decision lives in front of both engines, on the host the application points at: the edge.

I chose this engine pair because Next.js is the SDK’s first-class integration, the one its samples start from, and Astro is the engine from part 1. The migration direction is Next.js to Astro, one page type at a time, and the page type that moves first is the article page. Everything else, including the landing experience, stays on Next.js. I found no published strangler guidance from Optimizely, and its CMS 13 FAQ describes a change of architecture as a complete rewrite of the front end, which is a reasonable default position; this article asks whether a more gradual route is also possible.

Part 1 promised a production part on DXP, which is still ahead. The question of two engines came up sooner, and much of it can be measured without a DXP environment, so it goes first.

When this approach makes sense

This strangler is worth evaluating when a team has a large site to move incrementally, has to keep editors working through the transition, and cannot switch every page type at once. It is less attractive when the site can move as a whole, when a split by path or by section is enough, or when the cost of running two rendering engines outweighs a gradual cutover.

The alternatives differ in what they ask of the site and of the edge. Of the rows below, I built and measured just the page-type one.

Route byWhat it needsWhat it costs
path prefix or sectionpaths that already follow the enginea page type spread across sections cannot move on its own
public hostnameone public hostname per enginethe site and its links are split across hosts; behind a single public hostname the host no longer tells the engines apart
a static map of pathsa list kept in step with publishinga new or moved page goes to the wrong engine until the list is updated
page type from Graphthe type of each page from the indexa Graph query for every uncached path, and routing that depends on the index being up and fresh
a prebuilt index from path to enginea job that copies paths and types from Graph or the CMSa pipeline of its own, and a gap between a publish and the index

Routing by type solves one constraint: a new page under a new path lands on the engine of its type with no change at the edge, once Graph has indexed it. It pays for that with a dependency on the availability and freshness of Graph, which the sections below measure. A site whose paths already follow the engine needs none of this.

For a real project I would start with one public page type that both engines can render, a defined rollback window, and written acceptance criteria for preview, publishing, cache invalidation and availability.

What CMS 13 gives you

Three facts from part 1 carry the design. An application is one entry point with its hosts and a set of preview URL formats, which can differ per content type. I kept one, {host}/preview?key={key}&ver={version}&loc={locale}&ctx={context}, to which the CMS appends a short-lived preview_token; a format per type could name the engine for preview without a Graph call. Graph stores the application’s primary host, scheme included, in _metadata.url.base, and the SDK’s query.host option filters path lookups by it. And the content model belongs to one place; in this repository that place is still the Astro project, and the Next.js project imports a copy of the same content types without ever pushing them. The SDK’s CLI reference says config push never deletes anything, so a single owner is my own rule.

So the edge becomes the application host. I changed the CMS application astro_app through the CMS 13 REST API so that https://localhost:8787, locale en, is its primary host. Both engines run with OPTIMIZELY_APPLICATION_HOST=https://localhost:8787, so query.host points at the edge in Astro and in Next.js alike, and both read the same Graph index.

The CMS issues preview URLs with the new host immediately. Graph, however, kept https://localhost:3001 in url.base for the existing pages until they were indexed again, which a new publish or the Graph full synchronization job does; changing the application alone does not. Optimizely’s SaaS documentation asks for that job after a change of hostname; the CMS 13 page does not mention it yet.

I lost about forty minutes on that change through my own mistake. I had given the OAuth client extra rights on two pages with rows inserted straight into tblContentAccess, and a page whose explicit ACL held only that client’s entry was skipped by the Graph indexer entirely: the delta job removed its documents by path, and a full synchronization sent 80 items without those two pages. With the inherited entries restored next to the client’s, the same job sent 96 items and both pages came back with the edge host. The CMS 13 documentation explains it: Graph indexes only content the SearchIndexer role can read, and the CMS does not add that role to custom access rights. Use the CMS’s own access rights, keep read access for SearchIndexer when you grant test rights, and check the effective rights that result.

The shape

Figure 1: one application, two engines, one edge Figure 1. One CMS 13 application, one Graph index, one edge that routes by page type. Behind it the two engines carry the same integration contract from part 1 in two copies.

One CMS 13 application, one public host, one Graph index. The edge routes pages and the preview route by page type. Behind it the two engines carry the same integration contract from part 1 in two copies: a component registry for the same content types, a preview route with its reload, a request scope for the SDK’s context, and a webhook that drops the right cache entries. Astro takes page types over one at a time. At the edge, moving a type is one setting. The new engine’s registry ships first, and removal from the old one comes in a later release.

The measurements below use these labels:

LabelWhat it is
checks 1 to 8the 28 lines of part 1’s script; checks 1 to 6 are 26 of those lines
landing checkschecks 2, 3 and 5 pointed at a page on Next.js, 16 lines
E1every test path lands in its declared engine
E2a, E2bthe cost of the type lookup and of the preview dispatch
E3one engine down
E4the response times quoted in the text
Z0 to Z11assumptions about DXP, listed in the appendix

One module, two hosts

The decision is a module with no framework imports, shared/routing.ts:

export const MIGRATED_TYPES: ReadonlySet<string> = new Set(['ArticlePage']);

const ASTRO_PREFIXES = ['/_astro/'];
const NEXT_PREFIXES = ['/_next/static/'];
const NEXT_PATHS = ['/_next/image'];

export function fixedEngine(url: URL): Engine | null {
  const path = url.pathname;
  if (NEXT_PREFIXES.some((p) => path.startsWith(p)) || NEXT_PATHS.includes(path)) return 'next';
  if (ASTRO_PREFIXES.some((p) => path.startsWith(p)) || isWebhook(url)) return 'astro';
  return null;
}

export function decide(url: URL, type: string | null, migrated: ReadonlySet<string> = MIGRATED_TYPES): Engine {
  return fixedEngine(url) ?? (type !== null && migrated.has(type) ? 'astro' : 'next');
}

export function targetFor(engine: Engine, url: URL, origins: Origins): URL {
  // Never resolve the path against the origin: `//evil.example/` would replace the host.
  const target = new URL(origins[engine]);
  target.pathname = url.pathname;
  target.search = url.search;
  return target;
}

MIGRATED_TYPES is the whole migration configuration, and a Worker variable of the same name overrides it at deployment, in the Worker and in the Next.js shell. Each engine’s own assets have a fixed engine, because no page type can tell you where /_next/static/... belongs. So does the Graph webhook in the Next.js shell; the Worker sends the webhook to both engines. Everything else is decided by the page type, and an unknown path goes to Next.js, which answers 404 for it.

Figure 2: the same module in a Next.js proxy and in a Worker Figure 2. One routing module with no framework imports, run in a Next.js proxy and in a Cloudflare Worker.

The Worker imports that module and forwards the request with fetch:

upstream = await fetch(targetFor(engine, url, origins), {
  method: request.method,
  headers,
  body: request.body,
  redirect: 'manual',
  signal: abort.signal,
});

The Next.js shell imports the same module, as a build-time copy, in src/proxy.ts and uses its own rewrite as the proxy:

if (engine === 'astro') {
  response = NextResponse.rewrite(targetFor('astro', url, origins));
}

The second host rests on the Next.js documentation and one measurement. Next.js 16 renamed middleware to proxy and runs it on Node.js, the rewrite is a proxy by definition (“Produce a response that rewrites (proxies) the given URL while preserving the original URL.”), and the Multi-Zones guide documents a rewrite to another domain from proxy for a dynamic decision, such as during a migration. The measurement: through the Next.js shell, /real-article/ came back from Astro with Astro’s own X-Astro-Cache, Age and Cache-Control headers, and HEAD was answered from Astro’s cache entry.

One setting in next.config.ts followed: trailingSlash: true with skipTrailingSlashRedirect: true, because CMS paths end with a slash while the preview URL and the webhook do not, and without both flags Next.js answered one of them with a 308. The documentation describes the second flag as a tool for exactly this: “which can make incremental migrations easier”. A prebuild step copies the shared module into the Next.js project, so that a DXP package builds on its own while shared/ stays the one source.

The portability proof is the result table: every check that applies to both routing hosts passed through both, and the lines that did not pass are assertions written for one engine. In the raw counts, checks 1 to 6, 26 of the 28 lines, ran against three bases: Astro directly 26 of 26, the Next.js shell 24 of 26, the Worker 24 of 26. The two missing lines are identical: the landing page is a type that stays on Next.js, so the two checks that count Astro islands on it have nothing to count. Every other line passed through both shells exactly as it passed on Astro alone. Most of those lines look at the article page, so they prove that both shells carry Astro faithfully but say little about the Next.js engine itself. For that I ran checks 2, 3 and 5 against a landing page on Next.js: 10 of 16, the same result directly and through the Worker. Five of the six misses are cache checks that expect Astro’s X-Astro-Cache and its header shape, while Next.js reports x-nextjs-cache and sends its own Cache-Control; the sixth is covered in the preview section.

On the article page, over one kept connection, the Next.js rewrite took a median of 15.1 ms and the Worker 31.6 ms against 15.0 ms for Astro directly. On the final code, on Next.js 16.4.5 and through the same Worker, the article took 21.9 ms and the landing 27.6 ms with a new connection per request. The spread between runs was as large as the difference between the two ways of connecting, so read these as an order of magnitude.

The Worker needs a few lines the Next.js shell does not: it fans the webhook out, and it handles response encoding, which Node negotiates for the client on the Next.js side. The Workers runtime requests gzip or brotli from the origin and, if you do not read the body, “the same encoded data will be returned without needing to be compressed again”, which is what a client that accepts the encoding wants. Under wrangler dev, though, the incoming Accept-Encoding reads br, gzip whatever the client sent, so a plain curl got 4,320 bytes of gzip where the HTML is 17,850 bytes, and two preview checks failed for that reason alone. The client’s real value is in request.cf.clientAcceptEncoding, so the Worker reads that field, and only for a client that does not take the encoding it drops Content-Encoding and pipes the body through an empty TransformStream, which makes the runtime decode it. My first version did that for every response, decoding and encoding every page again to fix a dev-tool quirk.

Routing by page type, not by path

This is the trade-off at the centre of the design. On this site a path did not identify the engine, so the edge asks Graph for the page’s type: every page the edge has not seen costs one Graph query, a median of about 157 ms on a cache miss here, and Graph’s availability becomes a condition for routing. The rest of this section measures that cost and what limits it. The lookup uses the same four branches the SDK uses for getContentByPath, the application’s host plus the default or hierarchical URL with and without its trailing slash, restricted to pages. It is a deliberate simplification for two page types: it leaves out the SDK’s published-only predicate and takes limit: 1 and the first concrete type. test/lookup-differential.mjs compared it with getContentByPath on four pages and 12 path forms, the same in 60 of 60 comparisons:

query TypeForPath($host: String!, $path: String!, $pathNoSlash: String!) {
  _Page(where: { _or: [
    { _metadata: { url: { base: { eq: $host }, default: { eq: $path } } } },
    { _metadata: { url: { base: { eq: $host }, default: { eq: $pathNoSlash } } } },
    { _metadata: { url: { base: { eq: $host }, hierarchical: { eq: $path } } } },
    { _metadata: { url: { base: { eq: $host }, hierarchical: { eq: $pathNoSlash } } } }
  ] }, limit: 1) { items { _metadata { types } } }
}

The lookup takes the first entry of _metadata.types that does not start with an underscore, which for the two page types here is the concrete type, ArticlePage or LandingExperience. The Worker keeps the answer in the Cache API under a synthetic key: 60 seconds for a page, 5 seconds for a path with no page, because that path may get a page any minute. The webhook also deletes the path it resolved, so a page published under a path that was just empty is routed by its type on the next request in the location that handled the webhook; elsewhere the entry lives out its TTL. The Next.js shell keeps the same lifetimes in a module-level map, where losing the map costs only a lookup. Behind the Worker the shell does not look anything up: the Worker sends its decision in X-Edge-Signature, an HMAC over a timestamp, the engine, the purpose of the request, the method, the path and the query, because the engine’s own host can be reached without the edge. Without a valid signature a request gets a 403, apart from a constant answer on / described under The signature gate.

Measured on the Worker, a cache hit is 5 to 7 ms and a cold lookup a median of 157.5 ms across 20 unknown paths. The first request to a page the edge has not seen pays the lookup plus the engine’s render: 529.5 ms for the article on Astro and 555.2 ms for the landing on Next.js, against warm medians through the Worker of 47.1 ms and 51.8 ms. A deployment of the same Worker to the Cloudflare network, with the engines behind tunnels and from one location (WAW), gave the same pass counts, 3 to 4 ms for a hit and a median of 129 ms for a cold lookup.

Every unknown path is a Graph query, and anyone can send one, so cold lookups go through a Workers rate limit binding. It allows 100 per 10 seconds per Cloudflare location, in one bucket for all clients, because the limit protects Graph whoever is asking; previews have a bucket of their own, because a junk token still costs a Graph call. With a stub Graph, under wrangler dev, 300 random paths in 17 seconds made 200 Graph calls, and 100 of the paths got a 429 with Retry-After, while a page with a known type kept working from its stale answer. On the Cloudflare network the same binding was much looser: 184 random paths per 10 seconds drew no 429 at all, and 262 per 10 seconds drew 27 of them, with 20 requests ending in a 502 and a known page answering 200 throughout. Cloudflare’s documentation calls this API approximate and local to a location, so treat 100 as a configured number. When the Worker first takes traffic every URL is cold, so real pages and crawlers can draw that 429 too, and the budget needs sizing before a launch, perhaps with a durable index from path to type that answers misses without a Graph query. The Next.js shell has no limit; the signature gate keeps outsiders away from it.

Two details mattered. The lookup decodes the path first, because both engines decode it before they ask Graph; on the encoded form, /real%2Darticle/ found nothing and a migrated page went to Next.js. And paths under an engine’s asset prefix skip the lookup and with it the limit, so one with no file answers 404 without asking Graph.

In return, an editor can create a new page of a type that has moved and another of a type that has not, both under new paths, and nothing changes at the edge. I did that through the REST API: an article page under /strangler-article/ and a landing experience under /strangler-landing/, both in Graph within 25 seconds of publishing. Through the Worker and through the Next.js shell alike, the article came from Astro and the landing from Next.js. The REST API does not take a status on creation (Status cannot be assigned when creating a new content item.), so a page is created as a draft and published with :publish, and POST /content answers 201 with an empty body, so you either choose the key yourself or read it back from the parent’s /items.

The preview dispatcher

Figure 3: the preview dispatcher Figure 3. The preview dispatcher asks Graph for the draft’s type with the token itself, then proxies the request to the engine that owns that type.

The preview URL carries a key, a version, a locale and a token, and the token is a credential Graph accepts. So the dispatcher asks Graph for the type with the token itself, using the same filter getPreviewContent uses in the SDK, without its variation: { include: ALL }:

query TypeForPreview($key: String!, $version: String, $locale: String) {
  _Content(where: { _metadata: { key: { eq: $key }, version: { eq: $version }, locale: { eq: $locale } } }, limit: 1) {
    items { _metadata { types } }
  }
}

A rejected token is answered at the edge with 401, Cache-Control: private, no-store and X-Robots-Tag: noindex, and no engine renders anything; that answer took 49 to 60 ms. A good token costs one Graph round trip, 152 ms median for the article draft and 143 ms for the landing draft, with nothing cached, because a token is issued for one user. A draft Graph does not have yet gets a 404 with no-store, and a Graph failure a 503. The preview lookup has a budget of 3 seconds and the engine 5, longer than for public pages: with a budget of 800 ms one preview in 20 failed with a 503.

Then the request is proxied to /preview of the chosen engine with the query string untouched. The engine’s headers come back as they are, apart from the diagnostic headers, a Location rewritten to the public host, no-store on a 403, 404, 429 or 5xx, and Content-Encoding for a client that does not take the encoding. It is a proxy so that the frame the editor sees keeps the application host as its origin, the only host the CMS knows. Both engines name the CMS origin in frame-ancestors, and that does not change with the engine behind the proxy. Preview responses send Referrer-Policy: no-referrer, since a page’s assets would otherwise carry the token in Referer.

Through the Worker, check 3 of part 1, the seven preview checks, passed 7 of 7 for the article on Astro and 6 of 7 for a landing draft on Next.js. The one miss is an engine property and shows the same way without the edge: the check looks for the contentSaved listener as an inline script, which in Next.js lives in NextPreviewComponent’s client code. The same seven checks passed 7 of 7 for Astro through the Next.js shell as well.

Then I opened both drafts in the editor itself: the article in the CMS edit view, the landing in the Visual Builder outline. Each frame loaded https://localhost:8787/preview from the CMS on port 5100 and showed its draft, with assets from /_astro/ in one and /_next/ in the other. A changed heading, autosaved by the CMS, reached the frame on both sides, by different means. Astro reloaded the whole frame through the edge, once or twice per edit: the CMS sends two messages per save, and a later SDK release pairs them into one refresh; this ran on 3.1.0-beta.0. Next.js kept the document and fetched two ?_rsc responses, and the new heading appeared. Each Astro reload also left a 404 in the editor’s console from the CMS context store, Unable to load context with given URL, while the editor stayed on the same draft.

The article draft from Astro in the CMS edit view, through the edge The article draft from Astro in the CMS 13 edit view, loaded through the edge.

The landing draft from Next.js in Visual Builder, through the edge The landing draft from Next.js in Visual Builder, loaded through the same edge.

Plan for the token before this reaches a log pipeline. The Worker logs nothing itself, but next dev behind the edge logs the full URL with the token on every preview request, and the same query string reaches the request logs of every zone it passes. The tokens live five minutes, but a token still does not belong in a retained log.

Invalidation across two engines

Figure 4: the webhook fans out Figure 4. A publish has to reach both caches: the Worker checks the key once, signs the event and sends it to both engines.

A publish has to reach both caches, because the edge does not know which engine holds a cached copy of the path that changed. The Worker checks Graph’s key itself, refuses a body over 64 KB, reads the body once and sends it to both engines, with content-type and Origin passed through verbatim and a signature replacing the key, then aggregates:

const statuses = results.map(([, r]) => r.status);
const status = statuses.every((s) => s >= 200 && s < 300) ? 202 : Math.max(...statuses);

202 only when both engines accepted; otherwise the worst status, because Optimizely’s ISR setup skill says Graph retries a delivery that gets anything but a 2xx. The fan-out waits up to 10 seconds, the worst case of an engine that resolves, stamps and purges. The Next.js side got a route handler with the same contract as the Astro one from part 1: a foreign Origin is 403, a bad key 401, a malformed body 400, a draft event 202, and a published event resolves the path through Graph and revalidates it. Both handlers take that logic from shared/webhook.ts. That only means something if Next.js caches the page. My first version rendered every request with force-dynamic, so the new heading would have appeared with or without the webhook, and its check 7 proved nothing. The page now uses revalidate = 600 on a production build, and the check requires the old heading after Graph has the new version and before the webhook.

The webhook checks of part 1 passed through the edge with the codes they produce against Astro directly, and no cache headers on any of them. Check 7, a real publish through the REST API, worked for both engines through the edge. For the article on Astro: Graph served the new version 1.5 seconds after the publish, the webhook answered 202 with both engines at 200, the next request was a cache miss with the new heading, 952 ms from the webhook to the fresh render. For the landing on Next.js: Graph after 1.4 seconds, the old heading still served from the cache, 202 again, and the new heading 1,135 ms later. Across the thirteen publishes timed in this work Graph served the new version in 1.2 to 2.5 seconds eleven times, once in 4.3 and once in 88.3, so plan for the long tail. Optimizely’s known-limitations page gives 1 to 3 seconds for small event-driven changes.

A shared cache across instances

DXP’s Next.js guide says the default file cache stays on each instance, and it prescribes a cacheHandler on Redis. next/cache-handler.mjs is that handler, with a Map as the fallback. One detail cost me a round: on Next.js 16.4.1 a page is stored under /route-cache/APP_PAGE/<hash>/$/<path>, so turning a tag into a key does not find it. So the handler writes a timestamp per tag, and an entry whose render started before any of its tags was stamped counts as a miss. Taking that start from the oldest lookup still waiting for its write addresses the race where a render reads Graph, the webhook arrives, and the old page is stored afterwards. A background refresh after a stale hit now counts the stale lookup as its start; before that, an old page written over a second after a webhook passed as fresh.

The test that matters is two next start instances and one webhook, with a real Redis in a local container:

no shared cacheRedis
instance B, first requestMISSHIT, from A’s render
webhook on instance A200200
instance B, next requestHIT, the old pageMISS, a fresh render

The left column is the failure DXP’s guide warns about, measured. With Redis stopped under a running instance, the page still answered 200 from a fresh render and the webhook answered 500, so a lost invalidation gets reported. The first runs of this test misled me: without the Worker’s signature the Next.js shell hands /hooks/graph to Astro, so the webhook answered 200 without reaching Next.js. Through the two zones of the rehearsal below the webhook answered 202 with both engines at 200, and the landing page went from HIT to MISS.

The Astro side got the same care. With Redis configured and unreachable, its invalidation used to delete the local copy and report success; it now fails, so the webhook reports it. A read compares the entry’s render start with the invalidation marker, /x and /x/ are cleared together and again 2 seconds later, because a lookup already under way can still write, and the wait for a Redis connection is bounded at 250 ms, which costs a miss.

After stamping, both engines purge the CDN through the platform’s edge-cache call. In a Route Handler, revalidatePath marks the path for revalidation on its next visit, so the Next.js handler writes its invalidation stamps first and then requests the purge; neither step alone proves that every later response is fresh. Behind an edge the CDN, as I understand it, holds pages under the engine’s hostname, so OPTIMIZELY_CDN_HOSTNAME names it in both engines, and a failure answers 500 or 502. On the platform a missing purge setting fails instead of returning success.

Freshness has to cover a lost delivery. Here pages stay fresh for 600 seconds in both engines, either can serve a stale page for up to 1,800 seconds more, and the first request after expiry can still get the old page. On Next.js that took expireTime: 2400: at its default of a year, responses carried stale-while-revalidate=31535400 and the handler kept an entry for a day. With the setting, a real page on a local build answered s-maxage=600, stale-while-revalidate=1800, and its entry in Redis showed a TTL of 2,397 seconds just after the write. The Next.js handler no longer stores a not-found page, and that response is no-store for a CDN too, because otherwise a page published under the path could answer 404 until the TTL.

Two event types fall outside this. A bulk.completed event names a journal and no document; it used to fail the delivery with a 400 and now gets a 202 and is ignored, so a bulk change waits for the TTL. An expired or deleted page cannot be resolved with the public key, so it waits for the TTL as well, and Graph’s doc.expired arrives in a sliding window of one hour. Where an end date matters, handling deletion and expiry through an index from document to path is a condition for production, and a TTL alone does not meet it. Reading the code shows one more gap: if an event arrives before the index serves the new version, the next render caches the old page as fresh, and nothing here retries that.

When one engine, or Graph, is down

With Astro stopped, a request for a page of a migrated type gets 502 from the edge with Cache-Control: no-store and X-Engine: astro, pages of the other type keep coming from Next.js, and the webhook fan-out answers 502, which is meant to make Graph retry later. With Next.js stopped, the mirror image. Against https://127.0.0.1:8787 the script passed 50 of 50, in ten runs, five per engine. On the final code it passed 35 of 36 locally, where the miss was a correct 502 at 2,008 ms against the script’s limit of 2,000 ms, and 36 of 36 across the two zones, against a limit of 2,500 ms.

The 502 comes from a timeout of 1,500 ms on the wait for response headers, because the local runtime did not get a fast “connection refused” from the closed port. A new connection to localhost, about 220 ms on this machine, TLS and, when the type cache is cold, the lookup come on top. 1,500 ms is a value for this demo: set yours from the uncached p99 of real pages. My first version used AbortSignal.timeout on the whole fetch, which also covers reading the body: a response that streamed for 2.5 seconds arrived as a 200 and then broke off. A stub origin caught that, and none of the 28 checks could have, because no page here takes that long. With a timer cleared once the headers arrive, the same response arrives whole.

There is no fallback to the other engine, on purpose. In the migration this is for, the type that moved is gone from the Next.js registry, and a fallback would, in my reading, produce a 404 or an empty page instead of a 502 that says what happened. no-store keeps the CDN from remembering that 502. Cloudflare’s own 52x and 530 pages, and a challenge page recognised by Cloudflare’s cf-mitigated: challenge header, become the same 502 with the reason in a header, and any 5xx gets no-store. A challenge page passed through would appear on the public hostname and never clear, because, as far as I can tell, the visitor’s browser cannot solve a challenge issued for another zone.

The edge has a third dependency that the engine checks do not touch. Every page whose type is not cached needs it from Graph, so in the first version a Graph failure meant a 500 on every such page, and a Graph that hung meant a wait with no end: the client gave up after 12 seconds. Now a lookup has a budget of 1.5 seconds and every known type is kept for up to a day in that location’s cache as a stale answer. Against a stub Graph that fails, a page with a known type is routed as before, with lookup;desc="stale" in Server-Timing, and a page with no known type gets a 503 with Retry-After. Failing over to a default engine would be a guess about a page the edge knows nothing about. A partial GraphQL error, { data: { _Page: null }, errors: [...] }, counts as a failed lookup and not as a missing page, and when Graph says a page is gone, its stale type is removed. The Next.js shell runs the same lookup with the same budget inside Proxy, although Next.js itself says “Proxy is not intended for slow data fetching.”

Moving a type, and moving it back

The measurements so far ran with ArticlePage already on Astro, which shows that routing works and says nothing about a change of owner. So I ran the change itself, three times, on the Worker deployed for the rehearsal described below (test/cutover.sh). Next.js starts as the owner, the type moves to Astro, then back, each time by deploying the Worker with another MIGRATED_TYPES and no code change, while a poller requests the page one request at a time and records status and engine.

Run 1Run 2Run 3
requests polled12613397
failed1, a 50200
Next.js to Astro, stable after the deploy1.9 s3.7 s1.1 s
Astro to Next.js, stable after the deploy3.6 s1.8 s1.1 s
wrangler deploy, each way31.7 and 34.7 s29.4 and 29.9 s23.6 and 22.9 s
preview, Astro owning, then Next.js owning7 of 7, 6 of 77 of 7, 6 of 77 of 7, 6 of 7
webhook in each state202202202

In all three runs the sampled requests show one change of engine per move, with no return to the old owner. The 6 of 7 is the inline listener, as before. The one failure, the 16th request on Next.js in the start state, ran over the Worker’s budget of 1.5 seconds and got a 502. The 15 before it had answered 200, the first in 0.84 seconds, so it was no first render. The later runs had no failure, and the raw polls are in docs/evidence/.

Nothing here waits for a cache, because the caches hold only a page’s type. The decision is computed per request from the type and MIGRATED_TYPES, so a move takes effect with the deployment. The Cache API matters when a page itself changes type: cache.delete reaches one data center, other locations keep the old type for up to 60 seconds, and a stale type can serve for a day while Graph is down.

The order I would use, from these runs:

  1. Release the type in the new engine and leave it in the old one’s registry.
  2. Warm the new owner’s pages, through EDGE_WARM_PATHS or signed requests. An unwarmed page can run over the budget: the first real request after a start took 3.6 seconds on Next.js and 4.9 on Astro.
  3. Move it with MIGRATED_TYPES and check X-Engine per path.
  4. Keep the type in the old registry until the rollback window has passed. Set that window beforehand and rehearse the removal as a release of its own.
  5. To go back, deploy the previous setting. The webhook reaches both engines whichever owns the type.

The worst defect

Reading the Worker deployed for the rehearsal again showed that targetFor built its target with new URL(path, origin). A path of //example.com/ is protocol-relative there, so it replaced the host. I confirmed it on the live Worker: the public hostname served the page of example.com, and the request carried the visitor’s headers and a valid signature. Nothing in the 28 checks or E1 sends such a path. The target is now built from the origin with path and query set separately, and there is a unit test. I had seen a 403 on //real-article/ a day earlier and explained it wrongly as the engine normalizing the path. It was the Worker calling a host named real-article.

What a real site would add

This demo has one host, one locale and two public page types, and delivery practice lists what that hides.

  • Routing filters on one application host, so hostnames per language would return 404 until the host goes into the lookup and every cache key. Several languages on one hostname need a rule for identical paths and fallback in a query that takes limit: 1.
  • Any path that is not a page has no owner: forms and sign-in posts get a 405, media under /globalassets/ and Commerce catalog URLs do not resolve, and this repository’s Astro cache provider ignores the query string and the visitor, so a search page must not move without a per-type policy.
  • A shared block or a navigation change invalidates nothing, because the webhook knows one document and no pages that depend on it, so two engines can show two menus for as long as their TTLs differ. Blocks shared across both registries have to be built twice for as long as the migration lasts.
  • Experimentation variations are not passed through, and canonical URLs, hreflang and sitemaps have no owner across engines.
  • Rollback through a setting works while the type is still in the old registry. Once it is gone the registry has to come back first, and Cloudflare’s split between versions does not help with that.
  • Restricted pages: the single key sees only what Everyone can read, so a signed-in visitor’s page would go to Next.js and a 404. The SDK has had HMAC and bearer authentication for this since 3.1.0, at the price of caching.
  • A moved page keeps its old path in the cache until the TTL, because the webhook resolves only the page’s current url.default and url.hierarchical, which it now clears together.
  • A tab opened before a move still holds the old engine’s client code, so its navigation and Server Actions can reach an engine that no longer owns the page. On a fixture page, /strangler-probe/, a next/link to an Astro page ended in a full page load after two ?_rsc requests answered 200, so the visitor gets the right page at the cost of the two requests; the Multi-Zones guide prescribes a plain <a> across zones.
  • The router reads a page’s type and the engine reads its content in separate queries, so the two can see different revisions, and nothing here checks that they agree.
  • On cost, the rehearsal ran on the Free plan, where passing the 100,000 daily requests ends in error 1027 or a bypass of the Worker, so a production route should fail closed. Every request invokes the Worker, cached or not, because it has to run on every request that needs a routing or security decision. edge/wrangler.jsonc has no cache block, so Workers Caching is off: turned on, Cloudflare “checks the cache before running your Worker”, and a hit would skip the routing and the signature. The second front end is unpriced.
  • No request ID ties the Worker, Graph and the engines together, though a failed lookup and an unreachable engine leave a line in the Worker’s log.

For a first move I would pick a type that is public, uses no forms, query-driven renders or experiments, and shares no navigation or blocks.

The same Worker in front of DXP (orange-to-orange)

A routing decision is only worth as much as the platform it has to run on, and for this site that platform is DXP behind Cloudflare. So the next question was whether the same Worker can sit in front of two DXP front ends at all, and what that exposes.

Figure 5: the same Worker in front of DXP, documented facts and assumptions Figure 5. The same Worker in front of DXP: what the documentation establishes, and what stays an assumption (Z0 to Z11, listed in the appendix) until a DXP environment confirms it.

I first worked this section out from documentation, then ran the mechanism between two Cloudflare accounts, with a second account of my own in the provider’s place.

From the Cloudflare for SaaS documentation: orange-to-orange is traffic passing through two Cloudflare zones, the customer’s and the SaaS provider’s, and “O2O only applies when the two zones are part of different Cloudflare accounts.” It is activated by a CNAME, not by an A record at the apex. The customer zone’s settings apply first, and in the provider’s zone “a HTTP header will be set to cf-connecting-o2o: 1.” Its compatibility table allows Workers on the customer zone with a warning that a Worker matching the O2O subdomain can block or disturb traffic, and discourages caching HTML there.

From Optimizely’s DXP documentation: the hosting page says a back end “can have multiple front ends”, and that “the front ends are separate sites” talking mainly through Graph. The platform’s WAF “runs the OWASP ModSecurity Core Ruleset by default” with Cloudflare’s baseline rules, and rules beyond those belong in code or in your own Cloudflare zone, which that page calls orange-to-orange. The Bring your own Cloudflare Zone page names Workers among the reasons to use one, and asks for an active Cloudflare zone for the domain, a matching active DXP project, no pair of two Cloudflare for SaaS zones, and a hostname whose zone name ends with dxp.optimizely.com. The CNAME then points at that zone name, so the dxcloud.episerver.net target from the go-live guide does not apply to O2O. The page is in the CMS 12 and Commerce 14 documentation and says nothing about CMS 13 or front-end hosting. Outside Optimizely’s documentation, a post on David’s Blog of May 2026 describes the same steps on DXP: a “Full zone” value ending in .dxp.optimizely.com in the Hostnames tab and a proxied CNAME pointed at it.

Astro joined Next.js on front-end hosting on August 31, 2026, so both engines here are on the supported list. That rests on the release note and on the cms-12 and CMS SaaS copies of the hosting page, which name CMS 13 and Astro 5 and 7; the copy in the DXP documentation still says Next.js only and no ISR, so the two documentation sets are not yet aligned on this point.

Put together, the customer’s zone runs the Worker on www.example.com, and each front end gets its own hostname in that zone, a proxied CNAME to its DXP zone name. origins holds those two hostnames. My first design had the Worker call the Astro host directly; the rehearsal shows why that is the weaker route. The names in the figure are illustrative, and the routing module does not change.

The rehearsal

Two Cloudflare accounts on the Free plan, two zones. The provider’s side: Cloudflare for SaaS, a fallback origin as a proxied CNAME to a named tunnel, and one custom hostname per engine. The customer’s side: two proxied CNAMEs to the fallback origin and this Worker on a route of its own, with origins set to the two hostnames. The engines ran on my machine behind the tunnel. Hostnames became active with certificates a few minutes after the records went in; validation ran over HTTP, because Cloudflare supports no pre-validation for O2O hostnames.

A small logging proxy between the tunnel and the engines logged 401 requests, and each carried cf-connecting-o2o: 1 and cdn-loop: cloudflare; loops=2, so this was two zones in a row.

Worker on Cloudflare, no O2OWorker with O2O
E1, engine per path9 of 99 of 9
checks 1 to 6, article24 of 2624 of 26, the same two lines
checks 2, 3, 5, landing10 of 1610 of 16, the same lines
E3, one engine down30 of 3036 of 36
E2b, preview lookup116 and 116 ms118 and 126 ms
response, new connection120.3 and 125.8 ms146.7 and 132.4 ms

Each pair is the article, then the landing. The right column includes the logging proxy and a different tunnel, so read the response times as the difference between two whole setups.

How the Worker reaches the engine mattered more than anything in that table. I sent requests down three routes and read what arrived, with a throwaway probe Worker for the third:

Route from the WorkerO2Ocdn-loopcf-worker at the originclient IP at the origin
hostname in its own zone, default1loops=2absentnot the Worker’s
same, with global_fetch_strictly_public1loops=2absentthe Worker’s
hostname in another zone, directlynoneloops=1the customer’s zonethe Worker’s

The first row is the only one that is O2O with an address other than the Worker’s. On 6 of 6 requests through the Worker and both zones, CF-Connecting-IP at the origin was the client’s own address and X-Forwarded-For contained it; the probe compared hashes and stored no address. Cloudflare’s header reference covers one zone only, and Shopify users report the Worker’s address at the provider, so this result holds for this setup. The third row, the direct call my first design relied on, is not O2O: a DXP zone would see a Worker address for every visitor, which matters for its rate limits, its WAF and anything that logs a client IP, and a zone can refuse such calls with cf.worker.upstream_zone. So I treat the customer’s hostnames as the only route, and the Worker now refuses a DXP zone name in origins with a 503 before anything goes out, with the reason in a header and in its log, because such a setting would work until the day it was blocked and then look like an engine outage.

The cf-worker header needs care too: Cloudflare’s reference says a Worker subrequest adds it, yet it arrived only on the third route, so I would not build on it in either direction.

The signature gate

Minutes after the certificates were issued, about a hundred requests hit an engine’s hostname directly, asking for paths like /.env, /.git/config and /actuator/env. They found nothing sensitive, but the engine hostnames answered 200 without the Worker, and the static secret I had then was no gate: without it the Next.js shell did its own lookup and served the page, and Astro never read it. So both engines now refuse a request from outside unless it carries the Worker’s signature. It is valid for 60 seconds and bound to one engine, method, path and query, and a second secret variable allows a rotation. A missing secret answers 503 everywhere, and only an explicit switch for a local process opens the gate.

The first version left the query and the body out, and the webhook handlers took any valid signature in place of Graph’s key, while the Worker signs every visitor’s request. The signature now names its purpose: a visitor’s request is signed as a page, the webhook only after the Worker has checked the key, together with a digest of the body. A handler that gets a signature accepts nothing else. A proxied body is streamed and stays unsigned, so a POST such as a Server Action is bound to its path and query, whatever it carries. The signature therefore attests that a request came through the edge route and that the signed metadata is intact; it does not cover the integrity of the whole request, and it says nothing about any user’s permissions. There is no nonce either: a captured header can repeat a read for 60 seconds and a write for 15, so a write that must not run twice needs an idempotency key of its own.

Tests and rereading found holes on the way. Astro’s cache provider answered before the middleware ran, so a cached page came back 200 until the provider called the gate too; an exemption for loopback hosts trusted the Host header, and /favicon.ico/anything skipped the gate. The gate now reads only the signature. Hashed files under /_next/static/ stay public, because that prefix skips the proxy, and a later audit found that a path under it with no file reached the catch-all page unsigned: it asked Graph, wrote an ISR entry and, in a small test application with the same matcher, ran a Server Action. A route of its own now answers such a path, the bare prefix included, with a 404 and a POST with a 405, and a real hashed file still answers 200. test/gate-checks.sh now has 76 checks, these included, and all 76 passed locally on the final code. The Worker also drops client-supplied address headers: X-Real-IP, True-Client-IP, Forwarded and X-Forwarded-For set to a test address did not reach the origin, on any of 6 requests.

DXP’s smooth-deployment troubleshooting page requires a 200 on the root path during warm-up and lists a 403 from application-level IP filtering as a warm-up failure, so an unsigned GET or HEAD of / gets a constant ok, which needs no render and no Graph call; /api/health, or the path in EDGE_HEALTH_PATH, answers the same way for availability monitoring. The ok shows that the process runs and its request handler is reachable, and no more. It does not ask Graph or Redis, because their outage would otherwise take down every instance at once, and a slot that lost its secret answers 503 on /. The gate proves only that a request passed through the Worker, which signs public requests too, and it authenticates no user. A person testing a slot by hand sees ok and 403 without a signature, and sign() in test/gate-checks.sh shows how to make a signed request.

What changed for the platform

Checking the code against both platforms’ documentation changed a few more things.

The Worker set X-Forwarded-Host on every request, and that header is part of Cloudflare’s default cache key: on the rehearsal zones the same file with that header was a MISS and then a HIT of its own, and the purge call used here takes only URLs. The Worker now sends the header only on writes, where a Server Action needs it, because Next.js “compares the host in a request’s Origin header against the app’s own host”, taken from x-forwarded-host or host. In a browser through the local Worker the Server Action and the next/link to Astro both worked.

The constant ok renders nothing, so the first real request after a start paid for a cold engine, over the Worker’s budget. The first probe a process sees now starts a background render of EDGE_WARM_PATHS, signed by the engine itself, and the first request after it took 8 to 81 ms over three runs. test/warmup-check.sh runs both built engines the way a slot runs, 20 of 20, with an unsigned GET and HEAD, a foreign Host for a bound hostname and a slot without its secret.

Both engines now have a self-contained package that passes npm ci and npm run build from an unpacked copy outside the repository, Astro’s adapter no longer listens on localhost only, and next start takes its port from the environment. The packaging details are in the repository.

Node is the one gap on the Astro side. DXP states 22.12.0 as a minimum, and a container build pulls the latest patch of the selected major, per an Optimizely advisory of October 2026. Astro 7.4.2 needs Node 22.23.4 or later, and below it astro build exits, so there would be no running deployment to ask.

What the integration guides do not establish

A routing cutover is only useful if the destination engine serves the correct content. That made cache invalidation part of the migration experiment, not a separate optimization, and it is why the published ISR examples get a close reading here.

A test of a successful publish cannot tell a webhook acknowledgement from cache consistency. A synchronization that finished, a webhook that was delivered and a cache that was invalidated are three separate events, and a two-engine setup has to observe each of them on its own. My own Astro handler got this wrong at first, reporting success after clearing only a local copy.

Optimizely’s published ISR examples, read on November 5, 2026, are written the same way, which is reasonable for a starting point. This table comes from reading their code.

FailureNext.js exampleAstro exampleThis repository
Redis unavailable200, the error is swallowed200 with ok: true, invalidate catches it500
CDN purge fails200, the purge is not awaited200 with ok: true, logged502
document no longer resolves200, nothing invalidated404202, the page waits for its TTL

This matters because Optimizely’s ISR setup skill says Graph retries on a non-2xx, so a 200 gives up the retry and leaves the TTL. The same skill warns of a retry storm and reserves error codes for genuine authentication failures, so the 500 and 502 here depart from that advice on purpose: a lost invalidation should show. A failure on one engine also repeats the work on both, because each delivery fans out to the pair. A 2xx from the purge endpoint is no proof either: the guides show it accepts the request and works asynchronously. The fan-out’s 202 has the same limit: it reports what the engines answered, which is less than every visitor getting the new page.

Expiry is the hardest case. Graph’s webhook page says it does not retrieve expired content with the single public key, and the Next.js example resolves a path with that key. The ISR skill says unpublished pages usually still resolve, and for a null result it prescribes revalidating the layout or keeping a map from key to path. Neither the published handlers nor this repository does either of these, so if the query no longer returns the document, the handler has no record of which URL to clear.

One finding I reproduced. The Next.js example derives Redis keys from _N_T_ tags and deletes those. I ran its handler and webhook route on Next.js 16.4.1 with two next start instances and a local Redis 7.4, changing the handler only to connect with a plain client (the example targets the Azure cluster), and adding a render stamp to the page. The page was stored under nextjs:default:/route-cache/APP_PAGE/<hash>/$/strangler-landing. The webhook answered 200, and Redis received DEL nextjs:default:/strangler-landing, a key that does not exist. The entry stayed, and both instances kept serving the same render as HIT for the 25 seconds I watched, in both runs. With my handler and the same route and webhook, the next request was a MISS with a new render. The stored entry also had no TTL, because the example reads context.revalidate, which this version did not pass.

Then I checked versions. On a one-page application without the CMS, the same handler cleared the page on seven versions from 16.0.11 to 16.3.7, by the first read, one second after the webhook. On 16.3.8, 16.4.1 and 16.4.5 it did not. With a 25-second window the old render came back in 0 of 16 reads on 16.3.7 and in 16 of 16 on 16.3.8. The file route-cache-key.js is in the 16.3.8 package and not in 16.3.7, so the key the example depends on changed in a patch release. The README of @fortedigital/nextjs-cache-handler, a project I have nothing to do with, names the same boundary and the same key form, “Starting with Next.js 16.3.8 (and 16.4.x)”, and that package shipped a fix in 3.4.0. The TTL was missing on all ten versions. The steps are in docs/finding-nextjs-isr-example.md.

A few things I looked for in Optimizely’s documentation, release notes and SDK repositories and did not find; if any of them exists and I missed it, a correction would be welcome and I would gladly update this text:

  • the count, schedule, timeout and ordering of Graph’s webhook retries;
  • for CMS 13, that a changed application host leaves url.base stale until pages are indexed again; the SaaS documentation covers the job;
  • that the delta job deletes a page’s documents by path when its ACL lacks SearchIndexer; the removal from the index is documented, this route is what I measured;
  • a guaranteed bound from publish to Graph; the known-limitations page gives 1 to 3 seconds for small event-driven changes, which fits eleven of my thirteen publishes;
  • how a preview token behaves for a variation’s draft without variation: { include: ALL };
  • a health-check path, method or source, a custom hostname or O2O, and the provisioning of a second front end, all on front-end hosting;
  • a strangler pattern for one content tree.

Results at a glance

Each row points to a section above.

FindingEvidenceImpactStatus
A path of // made the Worker call another hostreproduced on the deployed Workeranother site’s page on the public hostname, with the visitor’s headers and a valid signaturefixed, with a unit test
A path under /_next/static/ with no file reached the catch-all page unsignedlocal build, before and afterGraph queries and ISR writes without a signature; a Server Action ran in a test applicationfixed, including the bare prefix a later review found; 76 of 76 checks locally
The published Next.js ISR example did not clear the tested page on Next.js 16.3.8, 16.4.1 and 16.4.5ten versions, script in the repositorythe webhook answers 200 and the old page staysreproduced on local Redis, open in the guide
A background refresh could store an old page as freshunit testan old page passes as fresh after a webhookfixed
Next.js defaults to about a year of stale-while-revalidateresponse headers, Redis TTL on a real pagea CDN that honours it could hold a stale copy far longerset to 2,400 seconds
An explicit ACL without inherited entries hid pages from Graphfull synchronization, 80 against 96 itemspages disappear from the indexexplained

The question splits into four claims.

ClaimResultSection
H1: one CMS 13 application and one content model can serve two rendering enginesholds on this setup for routing, preview and invalidation with webhooks sent by the testsThe shape, The preview dispatcher, Invalidation across two engines
H2: the routing decision runs unchanged in a Next.js proxy and in a Workerholds functionally: checks 1 to 6 of part 1 and E1 gave the same result through bothOne module, two hosts
H3: one page type can move to the other engine and back with no change to the routing codeholds for one type under scripted traffic in three runs, with one 502 before the first switchMoving a type, and moving it back
H4: the same mechanism works in front of DXPthe edge gave the same pass counts in an orange-to-orange rehearsal on two Cloudflare accounts; not established on DXPThe same Worker in front of DXP (orange-to-orange)

How I tested it

Every code excerpt is from the companion repository, pino-labs/astro-visual-builder-cms13, branch part-2-strangler. shared/routing.ts and shared/lookup.ts come with unit tests, and so do shared/webhook.ts, the ISR handler and the edge signature: 51 passing on Node 24, plus 13 for Astro’s cache in astro/test, all through npm test at the root. edge/src/index.ts is the Worker; next/src/proxy.ts and next/src/app/hooks/graph/route.ts are the Next.js side; the Astro project is the one from part 1 plus the changes this article lists.

Versions: CMS 13.5.0 with Optimizely Graph, @optimizely/cms-sdk 3.1.0-beta.0 in both engines, Next.js 16.4.1 with Turbopack, then 16.4.5 for an advisory on image optimization (GHSA-7qx7-33qx-rqx2) that covers releases up to 16.4.4, Astro 7.4.2 with @astrojs/node 11.1.9, wrangler 4.157.0, Node 24.19.0, Windows 11. The main local run of November 3 was on Next.js 16.3.6, the Cloudflare network run, the O2O rehearsal and the DXP preparation work on the Next.js engine on 16.4.1, and the final run of November 8 on 16.4.5. Across these versions the checks, E1 and the webhook checks gave the same pass counts, apart from one E1 run of 8 of 9 after a restart on 16.4.1, with a 502 that fits a cold start.

The checks: the 28 from part 1 ran three times, with BASE set to Astro directly (http://127.0.0.1:3001), to the Next.js shell (http://127.0.0.1:3000) and to the Worker (https://localhost:8787), each with a preview token freshly issued by the CMS REST API. Checks 7 and 8 ran through astro/test/real-checks-7-8.mjs against the edge, and a second check 7 for a page type on Next.js through test/real-check-7-next.mjs. On top of them: E1 (test/edge-checks.sh, including the two pages created through the REST API), E2a (test/e2a.mjs), E2b (test/e2b.mjs), E3 (test/e3.sh) and E4, medians of 20 sequential requests after two warm-ups (test/latency.mjs), taken against https://127.0.0.1:8787 because on this machine localhost resolves to IPv6 first and wrangler listens on IPv4 only. The gate answers a direct request with a 403, so the Next.js shell column comes from the final code with the gate switched off.

Astro directlyNext.js shellWorker
checks 1 to 6, article (26 lines)26 of 2624 of 26, 24 of 24 applicable24 of 26, 24 of 24 applicable
checks 2, 3, 5, landing on Next.js (16 lines)not applicable10 of 16, 10 of 10 applicable10 of 16, 10 of 10 applicable
check 7, publish to fresh renderpart 1not measuredAstro 952 and 1,506 ms, Next.js 1,135 and 1,115 ms
check 8, no secrets in Astro’s dist/clientpassnot applicablesame check; the Next.js bundle was not scanned
E1, engine per pathnot applicable9 of 99 of 9
E2a, type lookupnot applicableone sample, 71 ms, next dev5 to 7 ms hit, 157.5 ms cold; final code: 3 to 4 ms and 146 ms
E2b, preview dispatchnot applicablenot measured152 and 143 ms; final code: 136 and 135 ms; bad token 52 and 51 ms
E3, engine downnot applicablenot measured127.0.0.1 50 of 50; final code: local 35 of 36, two zones 36 of 36

In 24 of 26 and 10 of 16 the misses are all lines that do not apply to that engine: the two article lines count Astro islands on a page that stays on Next.js, five landing lines expect Astro’s cache headers, and the sixth looks for the preview listener as an inline script. Every line that applies to both engines passed. The raw output of the final run is in docs/evidence/reg-2026-11-08.txt.

The failure cases that shaped the final Worker ran against a stub origin and a stub Graph, because a healthy system cannot produce them: a body slower than the timeout, a websocket, a conditional request, an origin that compresses, Graph answering 503 or 401 or errors or nothing, and a page published under a path that was just empty. Part of that harness is in the repository, as test/challenge-check.sh and test/type-cache-check.sh.

The Worker’s response headers X-Engine, X-Page-Type and Server-Timing exist for these measurements; keep X-Engine through a move, then remove all three from public responses.

Limits in brief

Nothing here ran on DXP. The orange-to-orange rehearsal used a second Cloudflare account of my own in the provider’s place, without DXP’s WAF, bot protection, CDN or front-end hosting, and most timings come from wrangler dev on one machine. Graph’s own webhook delivery was not tested, because no webhook was registered, so event shapes and retries come from the documentation. The type move ran from one location, one request at a time, with both engines still registering the type and no editor at work. Two assumptions can stop the design on DXP: that a second front end can be provisioned on one environment (Z0), and that a front-end project accepts a customer hostname with a dxp.optimizely.com zone name (Z1). The full list of untested cases and open assumptions is in the appendix at the end.

Verdict

Yes for routing and preview, and for invalidation with webhooks sent by the tests, on this setup: page type by page type, as far as one type and scripted traffic can show. It moved across and back in three runs. The decision is portable. One module with no framework imports made the same choices in a Next.js proxy and in a Cloudflare Worker, and checks 1 to 6 of part 1 and E1 gave the same result through both. That is functional portability, and behind the gate the shell decides nothing itself: the engine comes from the signature. What is not portable is everything each engine has to carry for itself: the registry for the same content types, the preview route, the request scope, the webhook. Part 1 listed those for Astro; this part shows that the list has to be satisfied twice when a second engine joins, by two implementations that can share code but not responsibility, and the edge only decides who gets the request.

The difficult parts are preview, invalidation, the edge’s own dependencies and security. In this design preview needs the edge to understand the token, because the type lookup is the token’s first use; a preview format per type could avoid that. Invalidation needs the edge to tell both engines, because it cannot know which one cached the page. And the lookup makes Graph a dependency of every page whose type is not cached, while a fronting Worker that builds URLs and signs requests can become an open proxy through a single bad path. Each of these is a small amount of code, and most of the bugs in it got past the 28 checks: failure cases against a stub origin found more of them than the checks had.

In the rehearsal the mechanism worked in front of a second Cloudflare account: the Worker, two zones on two accounts and a real cf-connecting-o2o gave the same pass counts as the Worker alone. DXP’s O2O page mentions Workers as a use of your own zone and says nothing about routing to two front ends.

My call today: the routing mechanism is proven in this test setup and the architecture is not validated on DXP, so no for production and yes for a test environment. The proof covers a routing cutover between two compatible engines, which is less than the life of a migrated page type. Three experiments would settle the most, and each could also rule the design out:

  1. Two front ends with customer hostnames on a DXP test environment. If a front-end project cannot take a customer hostname, this O2O layout is out.
  2. Graph’s own webhook delivery to both engines, for real publish, delete and expiry events, with a freshness budget set beforehand, and a stub of Graph that holds the new version back while the webhook arrives. If an old or no longer public page is served as fresh with no retry, the remedy is a retry that checks the version for a publish and a map from document to paths for a deletion or an expiry.
  3. A move of one type under concurrent traffic with an editor at work, once with the type still in the old registry and once with it removed. If a rollback from the second state takes longer than the team accepts, the old registry has to keep the type for the whole rollback window.

Until then this is a working prototype and a useful migration experiment, and no validated deployment pattern for DXP.

Repository: pino-labs/astro-visual-builder-cms13, branch part-2-strangler, the Worker, the Next.js shell, the shared module and every script named above.

Series: Part 1: What Astro Has to Rebuild on CMS 13 PaaS · Part 2: The Routing Decision Travels, the Engine Contract Doubles

Sources

All links checked on 2026-11-08.

Appendix: detailed limits of the experiment

Nothing here ran on DXP. The orange-to-orange rehearsal used a provider zone of my own, without DXP’s WAF, bot protection, CDN settings or front-end hosting, on the Free plan, from one location, with the engines on one machine behind one tunnel and the CMS application host left at the local edge. Most timings come from wrangler dev on one machine, with no CDN, WAF or real cf-* headers, and the main local run on Next.js 16.3.6 predates the signature gate. The final run on 16.4.5 ran locally and not across the two zones. None of this is evidence for an in-process Razor site, for CMS 12, for Commerce or for hostnames per language.

These DXP assumptions are open:

  • Z0: a second front end can be provisioned on the same environment; the hosting page says so in brackets, with no steps or entitlement.
  • Z1: a front-end project accepts a customer hostname with a dxp.optimizely.com zone name. The design needs it twice, and it is the one that can stop the design.
  • Z2: DXP’s WAF, bot protection and Project Security let the Worker’s requests through; the rehearsal zone had no such rules, and “I’m under attack” was never switched on in a real zone.
  • Z3: responses for the customer’s domain are not cached twice without a consistent purge across the engine’s cache, DXP’s CDN and the customer zone; the CDN purge call has never run for real.
  • Z4: the engine behind a DXP host renders URLs for the customer’s domain; the application host stayed local, so no such URL was rendered.
  • Z5: preview from the editor works through the Worker under O2O; scripted preview passed across the two zones, the editor frame ran only through the local Worker.
  • Z6: cf-connecting-o2o: 1 reaches the engine’s container on DXP, as it reached the origin in 401 of 401 rehearsal requests.
  • Z7: one webhook registration on the edge’s host can replace the registrations DXP provisions per front end through OPTIMIZELY_SITE_HOSTNAME and the callback key.
  • Z8: one headless CMS application can name the edge’s host as Primary and Preview.
  • Z9: DXP’s warm-up and health probes accept the constant 200 on /; their HTTP method and source are not documented for front ends.
  • Z10: HTML cached in DXP’s CDN under an engine’s hostname is served without reaching the gate; whether that exposure is acceptable is undecided.
  • Z11: I read Cloudflare’s rule against traffic between two custom hostname zones as not applying to this route; whether Cloudflare supports it or it merely works today is unconfirmed.

Not tested: Graph’s own webhook delivery, because no webhook was registered (Graph cannot reach localhost, and its instance is shared with another local CMS), so event shapes, retries, bulk.completed and doc.expired come from documentation; a repeated delivery; an event arriving before the index, whose script test/webhook-before-index.mjs has not run because the OAuth client lost its publish rights; a publish during a slot swap. The type move ran from one location, one request at a time, with a webhook sent by the script and both engines still registering the type; a move with an editor at work, a removal from the old registry, a rollback after it, a percentage rollout and an open tab during a move are untested. Also untested: variation drafts in preview, several languages, sessions across engines, load on the lookup inside Proxy, the Cache API across locations, Azure Managed Redis, a Linux build of the package, the background-refresh fix in a full next start, check 7 on the final code, and the effort, running cost and gains of the approach compared with its alternatives. The lookup was compared with the SDK only on four pages.

Causes I did not establish: the 502 in run 1 of the move, the 20 requests that ended in a 502 under the rate limit on the network, and the publish that took 88.3 seconds to reach Graph.

Confirmed gaps that remain: a deleted or expired page waits for its TTL, and with doc.expired arriving in a one-hour window an expired page can stay public for a while; bulk invalidation is not implemented; shared blocks and navigation invalidate nothing; the gate does not cover pages served from a CDN. Astro 7.4.2 needs a newer Node than DXP’s stated minimum, and only a first deployment can confirm which version the platform builds with.


Stack: Optimizely CMS 13.5.0 (Alloy-based, .NET 10) with Optimizely Graph, @optimizely/cms-sdk 3.1.0-beta.0 in both engines, Astro 7.4.2 with @astrojs/node 11.1.9, Next.js 16.4.1 and 16.4.5 with Turbopack, a Cloudflare Worker under wrangler 4.157.0, Redis 7.4, Node 24.19.0 on Windows 11. Optimizely, Astro, Next.js, Cloudflare, React and Redis are trademarks of their respective owners; this article is not affiliated with them.

// tech stack
Optimizely CMS 13.5.0Optimizely Graph@optimizely/cms-sdk 3.1.0-beta.0Astro 7.4.2@astrojs/node 11.1.9Next.js 16.4.5Cloudflare Workerswrangler 4.157.0Redis 7.4Node 24.19.0