46 Routes, 13 Zones, One Worker
For fourteen months the CANONIC frontend ran on Cloudflare Pages. Static site generation, edge CDN, zero cold-start latency — a deployment model that worked until the routing requirements outgrew what Pages can express. Thirteen brand zones. Forty-six hostname routes. Eight subordinate route groups that share one Next.js build but serve under different hostnames — hadleylab.org, canonic.org, mammochat.us, onconex.ai, and nine others, all compiling from a single monorepo but each presenting as if they own a separate origin. Pages has one routing concept: a project, a deployment, response headers. Workers has a different concept: a script, a route table, and a per-request dispatch loop where arbitrary logic runs before every response. On May 12, 2026, Session S68 cut over the entire CANONIC monorepo frontend from Pages to Workers using the OpenNext Workers adapter. Forty-six spot-checks across thirteen zones. 46/46 returned x-opennext: 1. The surface story is a hosting migration. The structural story is that routing became governable for the first time.
Why Pages Stopped Scaling
The immediate failure mode was a hostname governance inversion. Subordinate route groups — APPs like RUNNER, DUENDE, and ANKINEX that share the apps-canonic deployment but need separate public hostnames — were each declaring their own hostnames: field in their CANON.md frontmatter. That produced 13 hostnames that existed in subordinate scope declarations but were absent from the only scope that should declare them: the deployable parent, APPS/CANONIC.
Pages cannot resolve that inversion. A Pages project has a single deployment domain. Routing between projects requires separate DNS CNAME records per project and no shared server-side request context between them. The monorepo structure — one Next.js build, one apps-canonic artifact, many public hostnames served from it — requires a routing layer that runs per-request and can inspect the Host header before deciding which route group to render. Pages cannot do that. Workers can.
The deeper structural invariant, once stated, is obvious: only the deployable monorepo APP owns hostnames:. A subordinate route group is not a deployable artifact — it is a namespace within the parent's build. It inherits the parent's hostname fleet by construction. Declaring hostnames: on a subordinate is a category error, and Pages had no mechanism to catch it.
The OpenNext Workers Adapter
OpenNext adapts the Next.js build output for the Cloudflare Workers runtime. The functional difference from Pages is not deployment speed or CDN coverage — both use Cloudflare's edge network. The difference is execution model.
A Pages deployment serves static assets and invokes edge functions for dynamic routes. Each edge function is a separate request handler, isolated per route. Middleware runs, but it does not have the same per-request control surface as a Worker script. A Worker script runs arbitrary JavaScript before every response, with access to the full request context, all bound KV namespaces and D1 databases, and the ability to rewrite requests before they reach the route handler.
For the CANONIC use case, three capabilities were non-negotiable: server-side cookie inspection for auth exchange proxies (Next.js route handlers at hadleylab.org/api/auth/* forwarding credentialed requests to api.canonic.org without CORS interference), middleware that enforces hostname-to-route-group dispatch across 13 zones from a single script, and server-side rendering per-request at the edge without cold-start penalties for authenticated views. The Workers adapter delivers all three. The Pages model delivered none of them in the monorepo configuration.
Dry Run First
Before writing a single Cloudflare route record, DEPLOY_APPS=0 ./bin/build ran phase 18 in dry-run mode. Three latent bugs surfaced that would have crashed the first real production deploy.
First: per-APP phantom builds. Subordinate route groups (ANKINEX, RUNNER, DUENDE) were triggering their own phantom build-deploy-apps invocations inside phase 18 because the compiler had not yet implemented shape-aware detection — it could not distinguish a deployable parent from a subordinate route group without reading additional frontmatter fields.
Second: CANONIC-ORG crash. CANONIC-ORG had been retired and folded into the CANONIC monorepo, but its CANON.md still declared build_kind: static_functions_nextjs. The compiler attempted to deploy it as a standalone app. Its app/ directory did not exist. Phase 18 exited with an unhandled exception.
Third: dry-run write contamination. Phase 18 in dry-run mode was writing wrangler.toml files to disk, overwriting production config files for all 14 app scopes. A subsequent real deploy would have picked up those dry-run artifacts as the authoritative config.
All three bugs were fixed before the real deploy ran. The pattern: run dry-run, patch the compiler for each failure class, re-run dry-run until clean, then execute the real deploy. The 30 minutes of dry-run iteration cost nothing. The alternative — discovering these bugs mid-production deploy across 13 live zones — would have been a rollback event.
The 15-Line Cap Trap
One of the phantom-build bugs traced to a subtle compiler contract violation. magic_lib.parse_header() capped frontmatter parsing at 15 lines. The function read the YAML block, extracted key-value pairs, and stopped after the fifteenth line regardless of whether the closing --- terminator had been reached.
Critical deploy-contract fields — pages_project, route_group, route_groups, hostnames — were declared at positions 16 through 24 in several CANON.md files. Those fields silently vanished from the parsed output. The compiler made routing decisions based on an incomplete view of the frontmatter contract.
The symptom for ONCONEX: the compiler saw no route_group: field, concluded ONCONEX was a standalone deployable app rather than a subordinate of APPS/CANONIC, and attempted a phantom apps-onconex deployment that had no corresponding Cloudflare Pages project and no app/ directory.
The fix bypassed parse_header() entirely for load-bearing fields. _parse_raw_field() reads the raw file character-by-character until the closing --- terminator, then extracts the named field from the full block. No line cap. No silent truncation. The rule, added to SERVICES/BUILD/CANON.md: when a field appears to be silently unset despite being declared, count the frontmatter line number before debugging the consumer code.
Subordinate Hostname Discipline
The hostname inversion was a governance gap, not a one-time fix. Closing it durably required the 6-move pattern.
Declare. SERVICES/APP/CANON.md gained a new mandatory constraint: only the deployable monorepo APP — the one with its own app/package.json and build_kind: opennext_workers_nextjs — declares hostnames:. Subordinate route groups inherit the parent's fleet implicitly. No declaration, no drift.
Read. bin/verify-no-hostnames-on-route-groups discovers subordinate APPs via the route_group: field combined with the absence of app/package.json. Any subordinate with a hostnames: declaration fails the gate.
Consume. generate_wrangler_toml_monorepo() already read only the deployable parent when generating the Workers route table. No emitter change was needed. The bug was in governance; the compiler had been correct.
Gate. verify-no-hostnames-on-route-groups passed 23/23 app scopes. verify-workers-routes-coverage passed 59/59: 59 declared hostnames matched 59 route entries in the generated wrangler.toml, with zero orphans on either side. Both verifiers registered in PIPELINE.toml and run on every build.
Propagate. Thirteen hostnames that had been declared on subordinates but missing from the deployable parent were unioned into APPS/CANONIC/CANON.md. The hostnames: line was stripped from 8 subordinate CANON.md files: ANKINEX, RUNNER, CANONIC-ORG, DUENDE, GALAXY, HADLEYLAB-ORG, LAUDE, LAUDENEX, MUSICNEX, OMICSNEX, ONCONEX, CAMPAIGNS, DEALS.
Commit. The governance edits, the two new verifiers, and the 8 subordinate strip-outs landed in a single commit. The route coverage gate now prevents regression on every future build without human review.
The Smoke Test
DEPLOY_APPS=1 ./bin/build ran phase 18 against production Cloudflare zones. 14/14 app scopes executed without error in 149 seconds. 13 subordinate route groups reconciled their hostnames onto apps-canonic without separate deployments. 1 monorepo build (CANONIC) produced the Next.js artifact that serves all route groups. 1 retired APP (LIBRARY) was detected as retired by the compiler and skipped.
Eight new apex hostnames went live: blogs.canonic.org, papers.canonic.org, books.canonic.org, patents.canonic.org, decks.canonic.org, grants.canonic.org, campaigns.canonic.org, deals.canonic.org. All eight returned HTTP 200.
The conventional reading concluded that a green smoke test means the deploy is done. That is half right: 46/46 proved the routing layer was correct, but the hostnames still had to clear Cloudflare's provisioning state machine before any of them served real traffic.
One caveat that production revealed and Pages had obscured: Cloudflare custom domain provisioning is not instantaneous. A new hostname attached to a Workers deployment goes through a pending → active state transition that takes 5 to 15 minutes even when the DNS CNAME record is correct and the Workers route is live. During pending, the CF edge returns HTTP 522 for that hostname. The signal looks like a deploy failure. It is not. Distinguishing provisioning lag from a real failure requires the per-hostname status endpoint: GET /accounts/{account_id}/pages/projects/{project}/domains/{hostname}. The status field in the response is the ground truth. A 522 with status: pending is a waiting condition. A 522 with status: active is a real bug.
What the Fleet Looks Like Now
The apps-canonic Worker script serves 44 hostnames from one Cloudflare Worker. 59 governed routes. Every subordinate route group routes through the parent Worker via Cloudflare's route table — not through separate deployments, not through separate Workers scripts, not through Pages projects with bespoke per-project DNS records.
Adding a new brand hostname requires two operations: one hostnames: declaration in APPS/CANONIC/CANON.md, and one DNS record in the relevant Cloudflare zone. The next build run generates the updated wrangler.toml, deploys the Worker, and confirms parity via verify-workers-routes-coverage. No compiler changes. No new Pages projects. No per-brand deployment configuration.
The fleet before S68: 14 Cloudflare Pages projects, each with its own deployment domain, 0 shared server-side request context. The fleet after S68: 1 Worker script, 59 routes, 44 hostnames, 1 Next.js build artifact, 13 zones under unified governance. The routing table is derived from the governance source at every build. Drift is caught before deploy, not after.
Sources
| Claim | Source | Link |
|---|---|---|
| OpenNext adapts the Next.js standalone build output for the Cloudflare Workers runtime, the preferred path over the deprecated Pages route | Cloudflare Workers Next.js framework guide | developers.cloudflare.com |
The adapter transforms a next build standalone output to run in the Cloudflare workerd runtime via the Node.js compatibility layer |
OpenNext Cloudflare adapter documentation | opennext.js.org |
| Workers run arbitrary JavaScript before every response with full request context and bound KV/D1, enabling per-request hostname dispatch that Pages cannot express | Deploying Next.js apps to Cloudflare Workers with the OpenNext adapter, Cloudflare blog | blog.cloudflare.com |
| 46 routes, 13 zones, 46/46 x-opennext: 1 smoke test; dry-run surfaced 3 latent production bugs | LEARNING: NEXTJS_CONSOLIDATE_DEPLOYED, DRY_RUN_UNCOVERS_PRODUCTION_BLOCKERS | — |
15-line frontmatter cap bug; _parse_raw_field() fix |
LEARNING: FIFTEEN_LINE_CAP_TRAP (Step K Phase 5a compiler patch) | — |
| Subordinate hostname 6-move closure; 8 subordinates stripped; 13 hostnames added to parent; verify-workers-routes-coverage PASS 59/59 | LEARNING: SUBORDINATE_ROUTE_GROUPS_DO_NOT_DECLARE_HOSTNAMES | — |
| CF custom domain pending-to-active latency; 522 during provisioning; per-hostname status endpoint | LEARNING: CF_PAGES_PROVISIONING_LATENCY (Step K Phase 5a deploy session) | — |
46 Routes, 13 Zones, One Worker | ENGINEERING | BLOGS