- Vrealmatic
- Cloudflare
- Multi-country website
Multi-country website from one codebase
Shared code, per-country customization and an own domain on Cloudflare Workers - the DatováMapa case study
How we built DatováMapa for several countries: one core, a contract each country fills, URLs and texts in the country's language, features based on what each country has, and each country as a separate worker on its own domain. Technically, with code samples from production. See it working right away at datovamapa.cz.
Contents
00 · Overview
Multi-country website at a glance
DatováMapa runs in several countries from one codebase. Each country has its own domain, language, page URLs, data sources and services, yet the application core exists only once and knows no country. This is how it is built: the boundary between core and country, the contract that guards it, localization and running each country as a separate worker on Cloudflare.
The architecture in short
- Core - routes, protocol, API, MCP, map and cache in
src/, without a single mention of a particular country - Contract - three types (
CountryConfig, content,CountryServices) define what a country must and may supply - Country - a
countries/<id>folder: config, data sources, content, translations and docs in the country's language - Selection before the build - the
src/countrysymlink picks one country, the bundle holds only that one - Own domain - each country is a separate worker with its own domain, D1 and deploys
What this article covers
| Topic | Chapter |
|---|---|
| What belongs in the core and what in a country | Core and country → |
| The contract that guards the boundary with types | Country contract → |
| How the core reaches a country without knowing it | Loading a country → |
| URLs and texts in each country's language | Localization → |
| Features only some countries have | Optional services → |
| How a core change never breaks another country | Checking every country → |
| Each country as a separate worker on its own domain | Own domains → |
| Rules that proved themselves | Lessons → |
DatováMapa is an open portal that brings public data from many sources together in one map. Vrealmatic designed, built and runs its architecture: Next.js 16 through OpenNext on Cloudflare Workers. Code samples are shortened from the production repository and database IDs are replaced with placeholders. As of October 2026.
01 · Architecture
What belongs in the core and what in a country
The whole architecture rests on one rule: the core must not assume a particular country or particular data. Everything that differs between countries belongs in the country folder. The core reads it only through the contract and copes with missing parts.
What goes where
| Area | Core (src/) | Country (countries/<id>) |
|---|---|---|
| Routes and pages | neutral English routes, layout, components | public URLs in the country's language (/mista × /miesta) |
| Protocol, API, MCP | schema, validation, endpoints, tools: the same and in English everywhere | nothing |
| Texts for people | the Czech source text in a t("…") call | translation catalog messages.ts and docs in docs/*.md |
| Identity | only the CountryConfig type | id, language, canonical domain, time zone, regions, map bounds |
| Data sources | proxy, adapters, cache, catalog | provider registry and adapters in providers/ |
| Gazetteer | search, municipality and region pages | municipalities, regions and boundaries (JSON from the country's own scripts) |
| Content | how it is rendered | curated maps, municipality theme blocks, editorial calendar, social profiles |
| Services | what happens when a service is missing | geocoder, municipality boundaries, regional figures (each optional) |
| Operations | worker.ts, cache, D1 migrations | its own wrangler.jsonc: worker, D1, domain, e-mail |
Why not a fork per country
Core + countries
- a bug fix or a new feature in the core applies to every country
- a new country = a new folder, its first version can be a skeleton
- differences between countries live in one place and are type-checked
A fork or branching on country
- every fix is ported by hand and the forks drift apart
if (country === "sk")scattered across the code that nobody can maintain- nobody knows whether a change broke a country nobody is working on
If the domains differ only in branding and canonical URL (white-label), you need neither a country layer nor a worker per domain. How to cache that safely is covered in One worker, multiple domains in the Workers Cache guide.
02 · Contract
The country contract: what it must and may supply
The contract is three types in the core. They say what a country must supply (config), what it may supply empty (content) and what it need not have at all (services). TypeScript enforces the shape: a country that leaves something out or names it differently fails the type check.
.
├── src/ # core: knows a country only through the contract
│ ├── app/ # routes with neutral English names
│ ├── lib/country-config.ts # contract: identity, language, domain, public paths
│ ├── lib/country-content.ts # contract: curated maps, place themes, calendar
│ ├── lib/country-services.ts # contract: optional services
│ └── country -> ../countries/cz # symlink to the selected country
├── countries/
│ ├── index.ts # registry of all countries (links between them)
│ ├── cz/
│ │ ├── config.ts # CountryConfig
│ │ ├── services.ts # CountryServices
│ │ ├── messages.ts # UI translations (empty for Czech)
│ │ ├── providers/ # the country's data sources
│ │ ├── content/ # curated maps, gazetteer, country outline
│ │ ├── docs/ # public documentation in the country's language
│ │ └── scripts/ # generators of the country's data
│ └── sk/ # the same shape + its own wrangler.jsonc
├── worker.ts # one entry point for every country
├── wrangler.jsonc # worker of the default country (cz)
└── scripts/ # use-country, check-countries, cf (deploy)CountryConfig: identity and URLs
The config is the only mandatory description of a country: who it is, which language it speaks, which domain it lives on and what its pages are called in its language. Keys for pages, files and documents are neutral (places, docs), values are in the country's language.
// src/lib/country-config.ts (shortened): everything the core may know about a country
export type PageKey = "places" | "maps" | "topics" | "sources" | "docs" | "register" | "unreviewed" | "datasets";
export type FileKey = "places.geojson" | "municipalities.json" | "sources.md" | "examples";
export type DocKey = "providers" | "ai-agents" | "ai-assistants" | "protocol" | "api" | "about";
export type CountryConfig = {
id: string; // "cz", the same as the folder name
locale: string; // "cs-CZ": language of texts for people
origin: string; // canonical domain: "https://datovamapa.cz"
bounds: [number, number, number, number]; // where the map opens
timeZone: string; // what "today" means
regions: { name: string; slug: string }[]; // top-level administrative units
basemap?: { tileJsonUrl?: string; tileUrl?: string; attribution?: string };
paths: {
pages: Record<PageKey, string>; // public URL segments in the country's language
files: Record<FileKey, string>;
docs: Record<DocKey, string>;
categories: Record<string, string>;
};
};// countries/sk/config.ts (shortened)
import type { CountryConfig } from "../../src/lib/country-config";
export const country: CountryConfig = {
id: "sk",
locale: "sk-SK",
origin: "https://datovamapa.sk",
bounds: [16.83, 47.73, 22.57, 49.62],
timeZone: "Europe/Bratislava",
regions: [
{ name: "Bratislavský kraj", slug: "bratislavsky-kraj" },
// ... 7 more
],
// until Slovakia has its own tile server: OpenFreeMap (OpenMapTiles schema)
basemap: { tileJsonUrl: "https://tiles.openfreemap.org/planet" /* , attribution: ... */ },
paths: {
pages: {
places: "miesta", maps: "mapy", topics: "temy", sources: "zdroje",
docs: "dokumentacia", register: "registracia", unreviewed: "neschvalene-data", datasets: "mapa",
},
// files, docs and categories the same way
},
};What a country folder must contain
| File | Contents | Can it be empty? |
|---|---|---|
config.ts | identity, language, domain, map bounds, regions, public paths | no |
providers/registry.ts | connected data providers and their adapters | yes |
content/maps.ts, place-themes.ts… | curated maps, municipality overview blocks, editorial calendar, social profiles | yes |
content/docs.ts + docs/*.md | public documentation in the country's language | no |
messages.ts | translations of the core's texts | yes, for the source language |
content/places.json… | gazetteer: municipalities, regions, boundaries (generated by the country's scripts) | no |
content/outline.json | country outline for the map and links between countries | no |
services.ts | optional services | yes |
wrangler.jsonc | the country's worker (only outside the default country) | - |
Every new field in the contract has to be filled in by every country. So add new things as optional (a service, empty content) and make them mandatory only when the core really cannot work without them.
03 · Loading
How the core reaches a country
The core always imports the country through one path, @/country/…. Behind it is the src/country symbolic link, which a script points at countries/cz or countries/sk before the build. The core never has to ask which country it is.
// the core reaches the selected country through one path only
import { country } from "@/country/config";
import { services } from "@/country/services";
import { registry } from "@/country/providers/registry";// scripts/use-country.ts (shortened): src/country -> countries/<COUNTRY>
import { existsSync, lstatSync, readlinkSync, symlinkSync, unlinkSync } from "node:fs";
import { join } from "node:path";
const root = join(import.meta.dirname, "..");
const id = process.env.COUNTRY ?? "cz";
if (!/^[a-z]{2,8}$/.test(id) || !existsSync(join(root, "countries", id, "config.ts"))) {
console.error(`Country "${id}" does not exist: countries/${id}/config.ts is missing.`);
process.exit(1);
}
const link = join(root, "src", "country");
const target = join("..", "countries", id);
const stat = lstatSync(link, { throwIfNoEntry: false });
if (stat && !stat.isSymbolicLink()) {
console.error("src/country must be a symlink, not a folder.");
process.exit(1);
}
if (!stat || readlinkSync(link, { encoding: "utf8" }) !== target) {
if (stat) unlinkSync(link);
symlinkSync(target, link, "dir");
}npm scripts do the switch themselves: both dev and build first point the symlink according to COUNTRY and generate the browser translation catalog. By hand, COUNTRY=sk npm run country is enough.
// package.json: dev and build switch the country by themselves
"predev": "tsx scripts/use-country.ts && tsx scripts/i18n.ts --write",
"prebuild": "tsx scripts/use-country.ts && tsx scripts/i18n.ts --write",
"country": "tsx scripts/use-country.ts"Why a symlink and not a runtime variable
Symlink before the build
- the bundle holds one country's data only, the worker does not grow with each country
- static imports and full types, no branching on country
- the type check runs against the real country, not a union of all of them
process.env.COUNTRY at runtime
- the bundler packs every country's data at once
- bundlers often cannot resolve a dynamic import with a variable path (
import(`./countries/${id}`)) - bad data in one country can break the build for all
Registry of all countries
Each country needs to know one thing about the others: that they exist. The DatováMapa map masks neighbouring states and offers the supported countries as links to their domains. A small registry independent of the symlink serves that purpose. It holds only config and outline, no data.
// countries/index.ts: every country, independent of the symlink
import type { CountryConfig } from "../src/lib/country-config";
import { country as cz } from "./cz/config";
import czOutline from "./cz/content/outline.json";
import { country as sk } from "./sk/config";
import skOutline from "./sk/content/outline.json";
export type CountryEntry = { config: CountryConfig; outline: [number, number][][][] };
export const countries: CountryEntry[] = [
{ config: cz, outline: czOutline.shape as [number, number][][][] },
{ config: sk, outline: skOutline.shape as [number, number][][][] },
];Even one country can outgrow the worker size limit. Large files (DatováMapa's healthcare data, for example) are imported lazily with await import(), and their small metadata sits separately in *.meta.json.
04 · Localization
URLs and texts in the country's language
Each country has URLs and texts in its own language: datovamapa.cz/mista, datovamapa.sk/miesta. Inside the application, though, nothing is translated by hand. Routes are neutral, links are built by functions and texts come from the country's catalog.
Page URLs in the country's language
- Routes in
src/apphave neutral English names (places,docs). Public URLs reach them throughrewritesgenerated fromconfig.paths. - Every link is built with
routeHref, never by hand. The same code produces/dokumentace/apiin Czechia and/dokumentacia/apiin Slovakia. - Internal URLs are never served.
worker.tsredirects a request for an internal route to the public one, so no page exists under two URLs.
// src/lib/paths.ts (shortened): neutral routes inside, the country's language outside
import { country } from "../country/config";
const { pages: PAGE_PATHS } = country.paths;
// routeHref("docs", "api") -> "/dokumentace/api" in cz, "/dokumentacia/api" in sk
export const routeHref = (key: PageKey, ...rest: string[]) => `/${[PAGE_PATHS[key], ...rest].join("/")}`;
// for next.config.ts: public segment -> internal route
export function pathRewrites() {
return Object.entries(PAGE_PATHS).flatMap(([internal, publicName]) => [
{ source: `/${publicName}`, destination: `/${internal}` },
{ source: `/${publicName}/:path*`, destination: `/${internal}/:path*` },
]);
}Texts: the source string is the key
The core writes texts in Czech right in the code, and t("…") doubles as the key. Czechia has an empty catalog, Slovakia translates the source into Slovak in countries/sk/messages.ts. A missing translation returns the source text, so a new feature in the core never breaks another country. In development the console reports the missing translation.
The browser does not get the whole catalog. A script picks only translations of texts from client components (messages.client.ts, generated before the build and not in git), and Next.js hands it to the browser through an alias. The server uses the full catalog.
// in the core: the Czech source text is the key
const text = t("Chyba: {message}", { message });
// countries/sk/messages.ts: Czech source -> Slovak translation (the Czech catalog is empty)
export const messages: Record<string, string> = {
"DatováMapa": "DátováMapa",
"## Dokumentace": "## Dokumentácia",
"[lon, lat] ve WGS84, např. [14.4378, 50.0755]": "[lon, lat] vo WGS84, napr. [17.1077, 48.1486]",
};// next.config.ts: the browser gets only the translations of client code, the server the whole catalog
turbopack: {
resolveAlias: {
"@/country/messages": { browser: "./src/country/messages.client.ts" },
},
},
async rewrites() {
return pathRewrites();
},COUNTRY=sk npm run i18n # sources, missing translations, unused keys
COUNTRY=sk npm run i18n -- --missing # missing sources as JSON, input for translation
COUNTRY=sk npm run i18n -- --check # fails when anything is missing or left overWhat is not translated
The protocol, API, MCP tools, error codes and JSON Schema are the same in every country and in English. A client or AI agent that knows one country knows them all. A country owns only what people read: page texts, documentation, place names and URLs.
05 · Services
Optional services and data sources
Countries do not have the same capabilities. Czechia has a public address geocoder (RÚIAN), Slovakia does not yet. The contract handles this with optional services: what a country lacks, the core switches off instead of crashing.
// src/lib/country-services.ts (shortened): every service is optional
export type CountryServices = {
/** Address, street or municipality search. */
geocoder?: { attribution: string; search(text: string): Promise<GeocoderHit[]> };
/** Municipality boundary by code. */
boundaries?: { download(code: string): Promise<Ring[][] | undefined> };
/** Figures for a region. */
regionStatistics?: { providerId: string; load(regionName: string): Promise<RegionFigures[]> };
};// countries/sk/services.ts: no address search yet, so the core switches it off
export const services: CountryServices = {
boundaries: { download: async (code) => shapes.get(code) },
regionStatistics: { providerId: REGION_STATISTICS_PROVIDER, load: regionStatistics },
};// src/app/[region]/page.tsx: no statistics service, no figures on the region page
const statistics = (await services.regionStatistics?.load(region.name)) ?? [];
// src/lib/server/geocode.ts: no geocoder, a clear error instead of a crash
const geocoder = services.geocoder;
if (!geocoder) throw new RequestError(t("Hledání adres a míst v této zemi zatím není dostupné."), 501, "geocoding_unavailable");- Without a geocoder place search returns
geocoding_unavailable, and neither MCP nor OpenAPI offersfind_placeat all. - Without regional figures a region page has only the list of municipalities and data, no figure cards.
- Without municipality boundaries the portal does not know the boundary of smaller municipalities and shows their data as not loaded, so it never reports counts for the wrong area.
Data sources belong to the country
Each country has its own provider registry (providers/registry.ts): Czechia the Czech Statistical Office, the hydrometeorological institute or city data, Slovakia its statistical office, the land survey authority, Bratislava and Košice. Adapters for interfaces outside the protocol (ArcGIS REST, custom JSON) live in the core and countries just use them. That is why a new country can start with an empty registry and add sources over time.
- A new capability = an optional service in the contract. A country supplies it once it has it.
- The core reacts to a missing service by switching the feature off, not with an error.
- A country is never blocked by what another country can do in addition.
06 · Checks
Checking every country with one command
The biggest risk of a shared core: you change it while working on one country and break another one you are not working on. So one command goes through every country and runs the same checks for each.
// scripts/check-countries.ts (shortened): every country, one after another
const countries = readdirSync("countries").filter((id) => existsSync(`countries/${id}/config.ts`));
const previous = basename(readlinkSync("src/country", { encoding: "utf8" }));
const run = (id: string, args: string[]) =>
spawnSync("npx", args, { stdio: "inherit", env: { ...process.env, COUNTRY: id } }).status === 0;
const failed: string[] = [];
try {
for (const id of countries) {
run(id, ["tsx", "scripts/use-country.ts"]);
if (!run(id, ["tsc", "--noEmit"])) failed.push(`${id}: types`);
if (!run(id, ["tsx", "scripts/check-docs.ts"])) failed.push(`${id}: docs`);
if (!run(id, ["tsx", "scripts/i18n.ts", "--check"])) failed.push(`${id}: translations`);
}
} finally {
run(previous, ["tsx", "scripts/use-country.ts"]); // back to the country selected before
}
if (failed.length) process.exit(1);| Check | What it catches |
|---|---|
tsc --noEmit | a country missing a new contract field, or a core that assumes one country's data |
check-docs.ts | broken samples and links in the country's docs, a region URL colliding with a route |
i18n.ts --check | missing or unused translations |
| registry and outline | a country missing from countries/index.ts or without outline.json |
- After every core change run
npm run check:countries, even if you deploy only one country. - A protocol change = a change to the schema and to the protocol docs of every country, and the check verifies it.
- At the end the script points the symlink back at the country selected before, so it never leaves you somewhere else.
07 · Domains
Each country on its own domain
Customization goes all the way down to infrastructure: each country is a separate Cloudflare worker on its own domain, with its own D1 database, rate limit, e-mail sender and secrets. An outage, bad data or a deploy in one country does not touch the other.
wrangler.jsonc in the country folder
The default country (Czechia) uses the root wrangler.jsonc, every other country its own file in its folder. Paths in it are relative to the file (../../worker.ts), so all countries share the same entry point and build output.
// countries/sk/wrangler.jsonc: every path is relative to THIS file
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "datovamapa-sk",
"main": "../../worker.ts",
"workers_dev": false,
"preview_urls": false,
"compatibility_date": "2026-08-12",
"compatibility_flags": ["global_fetch_strictly_public"],
"assets": { "binding": "ASSETS", "directory": "../../.open-next/assets" },
"images": { "binding": "IMAGES" },
// must match "name": a copied "datovamapa" would call the Czech worker
"services": [{ "binding": "WORKER_SELF_REFERENCE", "service": "datovamapa-sk" }],
"d1_databases": [
{ "binding": "DB", "database_name": "datovamapa_sk", "database_id": "<D1_ID_SK>", "migrations_dir": "../../migrations" }
],
// namespace_id must be unique in the account (cz uses 1001)
"ratelimits": [{ "name": "REGISTRATION_LIMIT", "namespace_id": "2001", "simple": { "limit": 5, "period": 60 } }],
"send_email": [{ "name": "EMAIL" }],
"version_metadata": { "binding": "CF_VERSION_METADATA" },
"vars": { "NOTIFY_FROM": "registracia@mail.datovamapa.sk" },
"observability": { "enabled": true },
"upload_source_maps": true,
"limits": { "cpu_ms": 750 }
}| Key | Czechia | Slovakia | Why separate |
|---|---|---|---|
name | datovamapa | datovamapa-sk | a separate worker, versions and logs |
d1_databases | datovamapa-d1 | datovamapa_sk | registrations and approvals belong to one country |
namespace_id | 1001 | 2001 | with the same ID the countries would share a counter |
NOTIFY_FROM | mail.datovamapa.cz | mail.datovamapa.sk | a sender on the country's domain |
WORKER_SELF_REFERENCE | datovamapa | datovamapa-sk | OpenNext calls itself |
Deploying one country
Every command (build, deploy, D1 migrations, approvals) goes through a script that switches the symlink by COUNTRY and adds --config for Wrangler. Nobody types it by hand, so a Slovak build can never end up in the Czech worker.
// scripts/lib/wrangler.ts: the country's worker and D1 for every wrangler call
import { existsSync } from "node:fs";
import { join } from "node:path";
export const root = join(import.meta.dirname, "..", "..");
export const countryId = process.env.COUNTRY ?? "cz";
const config = join("countries", countryId, "wrangler.jsonc");
/** "--config ..." for a country with its own config, otherwise nothing (cz uses the root file). */
export const wranglerConfigArgs = existsSync(join(root, config)) ? ["--config", config] : [];npm run deploy # Czechia: root wrangler.jsonc
COUNTRY=sk npm run deploy # Slovakia: countries/sk/wrangler.jsoncThe country's domain and links between countries
config.originis the country's canonical domain. Canonical, Open Graph, the sitemap and links in the docs point at it, and each country is a separate site in its own language for search engines.- The edge cache in
worker.ts(Cache API) keys on the full URL including the domain and the deployed version ID. Deploying one country empties only its cache. The Cache API does not work on*.workers.dev, so the workers haveworkers_dev: falseand run only on their own domains. - The map builds links between countries from the
countries/index.tsregistry: the neighbouring country's name in the current country's language (Intl.DisplayNames) and a link to itsorigin.
08 · Lessons
What proved itself
A core + countries architecture lives or dies by whether the boundary between them holds. These rules proved themselves while building and running it.
- A country must not leak into the core. The moment
country.id === "cz"or a hard-coded Czech URL appears insrc/, the boundary is gone. The difference always belongs in the country's config, content or a service. - Links only through functions.
href="/mista"would work only in Czechia, so every link is built withrouteHrefanddocHref. - Only a literal can be translated. The string in
t()must be a literal, otherwise the script cannot find it and the translation would be missing in just one country. - The contract grows optionally. A mandatory field would block every country until each fills it. New capabilities therefore start as an optional service or empty content.
- Always check everything.
tscagainst the symlink checks only the selected country, so every core change goes throughcheck:countries. - Worker config is never copied blindly. The same
namespace_idwould share a counter, a copied service binding would call someone else's worker. That is why a script adds--config, never a person. - Build and deploy are one step.
.open-next/holds only the last country built, so deploys go exclusively through the script that switches the country and builds.
As a result, a new country is mostly work with data: a new folder, config, gazetteer and translations. Slovakia launched as a skeleton with a gazetteer from Eurostat and OpenStreetMap, without data providers or services, and content keeps arriving as deploys of the Slovak worker only.
- The core knows no country. It knows only the contract: config, content and optional services.
- A country is a folder, not a fork. It enters the core through a symlink before the build, and the bundle holds only that country.
- URLs and texts in the country's language come from config and a catalog, while the protocol and API stay the same everywhere.
- What a country lacks, the core switches off. That is why a new country can start as a skeleton.
- Each country runs on its own domain as a separate worker, and every core change is checked against all of them.
Sources
Official documentation and further reading
The article is based on DatováMapa's production architecture and on the documentation of the tools it uses:
- Next.js rewritesPublic URLs in the country's language onto neutral routes.
- Turbopack resolveAliasA different module for the browser and the server.
- Intl.DisplayNamesCountry names in the current country's language.
- Wrangler configurationwrangler.jsonc, relative paths and bindings.
- Custom domainsAn own domain for each worker.
- Rate LimitingThe ratelimits binding and a unique namespace_id.
- OpenNext for CloudflareBuilding Next.js for Workers.
Related
- Cloudflare Workers CacheEdge cache in front of the worker, including one worker for several domains.
- Next.js on CloudflareStatic build, OpenNext worker, D1, R2 and incremental cache.
- Cloudflare wikiAPI tokens, deploying with a token, R2 from the command line.
- DatováMapaThe portal from the case study, a live demo of the architecture.
How to cite this publication (APA 7)Nepor, V. (2026, October 20). Multi-country website from one codebase: Next.js on Cloudflare. Vrealmatic. https://vrealmatic.com/cloudflare/multi-country-website
Launching your app in several countries?
We design a shared-core architecture and set up each country on its own domain on Cloudflare.