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 by | What it needs | What it costs |
|---|---|---|
| path prefix or section | paths that already follow the engine | a page type spread across sections cannot move on its own |
| public hostname | one public hostname per engine | the 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 paths | a list kept in step with publishing | a new or moved page goes to the wrong engine until the list is updated |
| page type from Graph | the type of each page from the index | a Graph query for every uncached path, and routing that depends on the index being up and fresh |
| a prebuilt index from path to engine | a job that copies paths and types from Graph or the CMS | a 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 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:
| Label | What it is |
|---|---|
| checks 1 to 8 | the 28 lines of part 1’s script; checks 1 to 6 are 26 of those lines |
| landing checks | checks 2, 3 and 5 pointed at a page on Next.js, 16 lines |
| E1 | every test path lands in its declared engine |
| E2a, E2b | the cost of the type lookup and of the preview dispatch |
| E3 | one engine down |
| E4 | the response times quoted in the text |
| Z0 to Z11 | assumptions 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. 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 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 13 edit view, loaded 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. 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 cache | Redis | |
|---|---|---|
| instance B, first request | MISS | HIT, from A’s render |
| webhook on instance A | 200 | 200 |
| instance B, next request | HIT, the old page | MISS, 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 1 | Run 2 | Run 3 | |
|---|---|---|---|
| requests polled | 126 | 133 | 97 |
| failed | 1, a 502 | 0 | 0 |
| Next.js to Astro, stable after the deploy | 1.9 s | 3.7 s | 1.1 s |
| Astro to Next.js, stable after the deploy | 3.6 s | 1.8 s | 1.1 s |
wrangler deploy, each way | 31.7 and 34.7 s | 29.4 and 29.9 s | 23.6 and 22.9 s |
| preview, Astro owning, then Next.js owning | 7 of 7, 6 of 7 | 7 of 7, 6 of 7 | 7 of 7, 6 of 7 |
| webhook in each state | 202 | 202 | 202 |
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:
- Release the type in the new engine and leave it in the old one’s registry.
- Warm the new owner’s pages, through
EDGE_WARM_PATHSor 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. - Move it with
MIGRATED_TYPESand checkX-Engineper path. - 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.
- 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
Everyonecan 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.defaultandurl.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/, anext/linkto an Astro page ended in a full page load after two?_rscrequests 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.jsonchas nocacheblock, 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: 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 O2O | Worker with O2O | |
|---|---|---|
| E1, engine per path | 9 of 9 | 9 of 9 |
| checks 1 to 6, article | 24 of 26 | 24 of 26, the same two lines |
| checks 2, 3, 5, landing | 10 of 16 | 10 of 16, the same lines |
| E3, one engine down | 30 of 30 | 36 of 36 |
| E2b, preview lookup | 116 and 116 ms | 118 and 126 ms |
| response, new connection | 120.3 and 125.8 ms | 146.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 Worker | O2O | cdn-loop | cf-worker at the origin | client IP at the origin |
|---|---|---|---|---|
| hostname in its own zone, default | 1 | loops=2 | absent | not the Worker’s |
same, with global_fetch_strictly_public | 1 | loops=2 | absent | the Worker’s |
| hostname in another zone, directly | none | loops=1 | the customer’s zone | the 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.
| Failure | Next.js example | Astro example | This repository |
|---|---|---|---|
| Redis unavailable | 200, the error is swallowed | 200 with ok: true, invalidate catches it | 500 |
| CDN purge fails | 200, the purge is not awaited | 200 with ok: true, logged | 502 |
| document no longer resolves | 200, nothing invalidated | 404 | 202, 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.basestale 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.
| Finding | Evidence | Impact | Status |
|---|---|---|---|
A path of // made the Worker call another host | reproduced on the deployed Worker | another site’s page on the public hostname, with the visitor’s headers and a valid signature | fixed, with a unit test |
A path under /_next/static/ with no file reached the catch-all page unsigned | local build, before and after | Graph queries and ISR writes without a signature; a Server Action ran in a test application | fixed, 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.5 | ten versions, script in the repository | the webhook answers 200 and the old page stays | reproduced on local Redis, open in the guide |
| A background refresh could store an old page as fresh | unit test | an old page passes as fresh after a webhook | fixed |
Next.js defaults to about a year of stale-while-revalidate | response headers, Redis TTL on a real page | a CDN that honours it could hold a stale copy far longer | set to 2,400 seconds |
| An explicit ACL without inherited entries hid pages from Graph | full synchronization, 80 against 96 items | pages disappear from the index | explained |
The question splits into four claims.
| Claim | Result | Section |
|---|---|---|
| H1: one CMS 13 application and one content model can serve two rendering engines | holds on this setup for routing, preview and invalidation with webhooks sent by the tests | The shape, The preview dispatcher, Invalidation across two engines |
| H2: the routing decision runs unchanged in a Next.js proxy and in a Worker | holds functionally: checks 1 to 6 of part 1 and E1 gave the same result through both | One module, two hosts |
| H3: one page type can move to the other engine and back with no change to the routing code | holds for one type under scripted traffic in three runs, with one 502 before the first switch | Moving a type, and moving it back |
| H4: the same mechanism works in front of DXP | the edge gave the same pass counts in an orange-to-orange rehearsal on two Cloudflare accounts; not established on DXP | The 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 directly | Next.js shell | Worker | |
|---|---|---|---|
| checks 1 to 6, article (26 lines) | 26 of 26 | 24 of 26, 24 of 24 applicable | 24 of 26, 24 of 24 applicable |
| checks 2, 3, 5, landing on Next.js (16 lines) | not applicable | 10 of 16, 10 of 10 applicable | 10 of 16, 10 of 10 applicable |
| check 7, publish to fresh render | part 1 | not measured | Astro 952 and 1,506 ms, Next.js 1,135 and 1,115 ms |
check 8, no secrets in Astro’s dist/client | pass | not applicable | same check; the Next.js bundle was not scanned |
| E1, engine per path | not applicable | 9 of 9 | 9 of 9 |
| E2a, type lookup | not applicable | one sample, 71 ms, next dev | 5 to 7 ms hit, 157.5 ms cold; final code: 3 to 4 ms and 146 ms |
| E2b, preview dispatch | not applicable | not measured | 152 and 143 ms; final code: 136 and 135 ms; bad token 52 and 51 ms |
| E3, engine down | not applicable | not measured | 127.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:
- 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.
- 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.
- 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.
- Next.js: proxy.js, the Node.js runtime default,
skipTrailingSlashRedirect, “incremental migrations” and the warning on shared modules - Next.js: Proxy, getting started, Proxy, slow data fetching and
fetchcache options - Cloudflare Workers: Cache, cached responses served without running the Worker
- Cloudflare Workers: Cache configuration, the
cacheblock and gateway entrypoints - Next.js: serverActions, the
Origincheck andallowedOrigins - @fortedigital/nextjs-cache-handler, README, route-scoped keys from Next.js 16.3.8
- Next.js: NextResponse,
rewrite()as a proxy - Next.js: turbopack, the root directory and the lockfile detection
- Cloudflare for SaaS: how it works, orange-to-orange, the CNAME requirement, the order of zone settings and
cf-connecting-o2o - Cloudflare Workers: Fetch, compression requested from the origin and the encoded passthrough
- Cloudflare Workers: Cache, the Cache API stays in one data center and
cache.deletepurges only that one - Cloudflare Workers: Rate limiting, the binding, approximate and local to a location
- Host a frontend with Optimizely, front-end hosting: the
cms-12copy, with Astro, several front ends per back end, separate sites, Purge Cache - DXP: Web Application Firewall, the platform WAF’s rulesets and orange-to-orange for anything beyond them
- DXP: Go live, custom hostnames as CNAMEs to
dxcloud.episerver.net - DXP: Bring your own Cloudflare Zone, the conditions for O2O on DXP and the CNAME to the DXP zone name
- DXP: Hosting options, where Astro appears among the hosting options
- DXP: Next.js ISR caching and Optimizely Graph webhooks, the per-instance cache and the Redis handler
- DXP: Astro ISR caching and Optimizely Graph webhooks, the same pattern for Astro
- CMS 13 release notes, Astro on front-end hosting, August 31, 2026
- Cloudflare for SaaS: product compatibility, Workers and caching on the customer zone under O2O
- Cloudflare: HTTP headers,
CF-Worker,CF-EW-Via,CDN-LoopandCF-Connecting-O2O - Cloudflare Workers: compatibility flags,
fetchto a Worker’s own zone andglobal_fetch_strictly_public - Cloudflare WAF: block Worker subrequests from other zones,
cf.worker.upstream_zone - David’s Blog: Unleashing Cloudflare’s Power: Setting up Orange-to-Orange (O2O) with Optimizely DXP, the “Full zone” value in the Hostnames tab
- DXP: Host a front end with Optimizely, Project Security in the portal, Medium or I’m under attack
- Cloudflare: Detect a Challenge Page response, the
cf-mitigatedheader - DXP: Warm up sites, warm-up on new instances, written for .NET sites
- DXP: Troubleshoot smooth deployment, a 200 on the root path during warm-up, without a word on front ends
- Next.js: Multi-Zones, rewriting to another domain from proxy,
<a>across zones - CMS 13: Application framework, preview URL formats per content type
- CMS 13: Conventions API migration guide, Graph and the
SearchIndexerrole - Cloudflare Workers: How the cache works,
fetchand the cache - Cloudflare: Cache keys, the headers in the default key
- Cloudflare: Purge by single-file, purging entries stored with those headers
- Optimizely: Node.js security advisory, a container build pulls the latest patch of the selected major
- Optimizely SDK: Authentication, what the single key can see, HMAC and bearer
- Optimizely SaaS: Applications, a full synchronization after a change of hostname
- Optimizely SaaS: Live preview, a preview token lives five minutes
- Optimizely: FAQs for CMS 13, changing the architecture
- Cloudflare WAF: Rate limiting troubleshooting, same-zone subrequests
- Flare: How we accidentally dropped half our traces, same-zone subrequests and the WAF, February 2026
- Cloudflare Community: Worker in front of Shopify, the address a provider sees, July 2026, users’ reports
- Cloudflare Workers: Limits, the Free plan and error 1027
- Optimizely Graph: Webhooks, event shapes,
bulkanddoc.expired - Optimizely SDK: CLI reference,
config pushnever deletes - Cloudflare for SaaS: Product compatibility, customer-zone settings and the limit on custom hostname zones
- Optimizely skills: ISR setup, Graph retries on a non-2xx
- Optimizely Graph: Known limitations, typical sync latency
- Optimizely SDK: Deploy to front-end hosting,
engines - Szymon Uryga: Deploying to Optimizely Frontend Hosting, custom domains through Support, June 2025
- Cloudflare for SaaS: pre-validation, no pre-validation for O2O hostnames
- episerver/content-js-sdk, the
nextjs-templatesample the Next.js engine starts from,getContentByPathandgetPreviewContent - Part 1: Visual Builder without Next.js, Part 1: What Astro Has to Rebuild on CMS 13 PaaS and its repository
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.comzone 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: 1reaches 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_HOSTNAMEand 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.