~/stepanic
All posts
CloudflareWorkersPagesReactSEO

Moving gis.domovina.ai from Cloudflare Pages to Workers without moving the domain

The map now runs on a Worker with static assets. A Worker route in front of the Pages custom domain made the switch without downtime, the same worker file runs on both platforms, and three behavioural differences were handled in it. Crawler-readable content went into the same worker instead of an Astro rewrite.

On 30 October 2026 gis.domovina.ai moved from Cloudflare Pages to a Worker with static assets. The domain was not moved: a Worker route on the zone takes the traffic before it reaches the Pages custom domain, so the switch had no downtime and rollback is one deleted line and a redeploy. The worker file is the same on both platforms. Three behavioural differences between Pages and Workers showed up, all handled in that file. In the same change the worker started writing readable content into the page for crawlers that do not run JavaScript, which was the only real argument for rewriting the site in Astro.

The site

gis.domovina.ai is an interactive map of Croatia: counties, 556 cities and municipalities, 6,759 settlements, football clubs and open-data layers.

part what it is
frontend React 18, Vite 5, MapLibre GL 4, Tailwind; about 11,300 lines of TS/TSX
build 969 static files, 81 MB; the largest is naselja.geojson at 21.9 MB
edge code one _worker.js, 199 lines before this change, 326 after
data GeoJSON produced by a Python pipeline in the same monorepo

On Pages the worker ran in Advanced Mode and handled every request: SPA fallback, Open Graph tags per entity route via HTMLRewriter, and Cache-Control headers, because _headers is not applied while a worker handles the request.

Why the stack stayed React, not Astro

React SPA (kept) Astro
the map the whole app one client:only React island; no gain
navigation between routes map stays mounted full page load unless View Transitions with transition:persist
Open Graph tags already injected by the worker built in
readable HTML for crawlers without JS missing built in
PWA, URL state, Playwright suite working to be rebuilt

The only row where Astro wins outright is readable HTML. The worker already parses the same lookup files it needs for that, so the content was added there instead of rewriting about 11,300 lines.

Content in #root for crawlers

On /, /klub/:slug, /jls/:slug, /zupanija/:slug and /poster/:slug the worker writes a heading, a description and links into the empty <div id="root">:

new HTMLRewriter()
  .on("head", new OgInjector(meta, canonical))
  .on("div#root", { element: (el) => el.setInnerContent(bodyHtml, { html: true }) })
  .transform(indexRes);

React 18 createRoot clears the container on its first commit, so a browser shows the map and a crawler gets text. The links form a graph: a county lists all of its cities and municipalities (54 for Split-Dalmatia), a municipality links to its county, a club to its stadium and its municipality, and the home page to all 21 counties and 18 posters. A club gets the municipality link only when its city field is itself a municipality, which holds for 437 of 1,014 clubs; the rest are in settlements.

A Playwright script checked four routes on the local worker, on workers.dev and on production: the injected <main> is gone after load, the map canvas is present and the console has no errors.

One worker file, two runtimes

name = "gis-domovina"
main = "public/_worker.js"
compatibility_date = "2025-10-01"
routes = [{ pattern = "gis.domovina.ai/*", zone_name = "domovina.ai" }]

[assets]
directory = "./dist"
binding = "ASSETS"
run_worker_first = true
not_found_handling = "single-page-application"

Vite copies public/_worker.js into dist/, where Pages runs it. Wrangler bundles the same file as main. public/.assetsignore keeps it out of the public assets on Workers; without it the worker source would be downloadable. run_worker_first reproduces Advanced Mode, where the worker sees every request. wrangler pages deploy ran without error with this Workers config in place, so both targets deploy from the same directory, selected by DEPLOY_TARGET in scripts/deploy.sh.

Cutover with a route instead of a domain move

Moving the custom domain from Pages to the Worker means removing it from Pages and attaching it to the Worker, which leaves a gap and needs DNS write access. The OAuth token from wrangler login has zone (read) only. A Worker route on a proxied hostname runs before the request reaches the Pages origin behind it, so the route was added and the Pages domain left in place.

The worker sets X-Runtime: worker from a [vars] entry that exists only in wrangler.toml, which shows which platform answered:

curl -sI https://gis.domovina.ai/ | grep -i x-runtime   # x-runtime: worker

Rollback: remove routes and run npx wrangler deploy. Pages serves the domain again with its last deployment.

Differences between Pages and Workers

Pages Workers static assets handled by
GET /index.html 308 to / (measured) 307 to / (per html_handling docs) worker returns the bare shell with 200
Access-Control-Allow-Origin: * on assets added by the platform not added worker sets it
_worker.js in the asset directory executed, never served would be a public file .assetsignore
/sw.js Cache-Control on the domain max-age=14400, noted in August 2026 no-cache, must-revalidate, as the worker sets it nothing; the override is gone

The /index.html row was wrong on Pages as well. vite-plugin-pwa precaches /index.html as the navigateFallback shell, and the request returned a redirect instead of the document. The worker now answers that path with the bare shell, without the home-page content, so the service worker does not show home-page text on other routes.

The CORS header has no known consumer. A search of the sibling repositories found nothing that fetches gis.domovina.ai/data from another origin. It is set to keep behaviour identical.

Limitations

  • No latency or time-to-first-byte measurement was taken before or after the move.
  • The crawler content is unmeasured. There is no data yet on whether search or AI crawlers index the entity pages differently.
  • On a slow connection the injected text can be visible before the JavaScript bundle runs. The duration was not measured.
  • The Pages custom domain is still attached. If the route is removed by mistake, Pages serves the domain silently, and a DEPLOY_TARGET=pages deploy is not visible while the route exists.
  • Whether Workbox stored the old redirect response correctly was not tested before the change.
  • The cause of the 14,400 s override on Pages was not identified. It disappeared with the move.
  • Pricing and request quotas of the two products were not compared for this traffic.

Next steps

After a week or two without problems: detach the domain from Pages, replace the route with { pattern = "gis.domovina.ai", custom_domain = true }, and remove X-Runtime and the RUNTIME variable.

Sources