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=pagesdeploy 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
- Migration notes in the repo — differences, rollback, open items
- The worker — public/_worker.js
- Commit 1839035 — crawler content in #root and the Workers config
- Commit 22ca824 — route in front of Pages
- Cloudflare — Migrate from Pages to Workers
- Cloudflare — Static assets HTML handling (index.html redirects)
- Cloudflare — Workers routes