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.

Vladimír NeporAuthorVladimír Nepor
Published
A multi-country website from one codebase: the core (routes, API, MCP, map, cache) knows a country only through a contract of config, content and optional services. Before every build the src/country symlink selects one country, and each country runs on its own domain with its own language, URLs, data sources and services.Coresrc/app · routesAPI · MCP · mapcache · registrationknows no countryCountry contractCountryConfigcontent · services?@/country/…symlink before the buildcountries/czdatovamapa.czcs-CZ · /mista · 3 servicescountries/skdatovamapa.sksk-SK · /miesta · 2 services02 · CONTRACTWhat a country suppliesconfig · content · serviceschapter →03 · LOADINGThe src/country symlinka bundle with one country onlychapter →04 · LOCALIZATIONThe country's URLs and texts/mista × /miesta, t("…")chapter →07 · OWN DOMAINA worker per countryCOUNTRY=sk npm run deploychapter →
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

  1. Core - routes, protocol, API, MCP, map and cache in src/, without a single mention of a particular country
  2. Contract - three types (CountryConfig, content, CountryServices) define what a country must and may supply
  3. Country - a countries/<id> folder: config, data sources, content, translations and docs in the country's language
  4. Selection before the build - the src/country symlink picks one country, the bundle holds only that one
  5. Own domain - each country is a separate worker with its own domain, D1 and deploys

What this article covers

TopicChapter
What belongs in the core and what in a countryCore and country →
The contract that guards the boundary with typesCountry contract →
How the core reaches a country without knowing itLoading a country →
URLs and texts in each country's languageLocalization →
Features only some countries haveOptional services →
How a core change never breaks another countryChecking every country →
Each country as a separate worker on its own domainOwn domains →
Rules that proved themselvesLessons →
Case study: DatováMapa

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.

Three layers: the core on top is the same for every country (routes, protocol, API and MCP, map, cache, registration). It talks to a country only through the contract in the middle. Below, each country fills the contract with its own language, URLs, data sources, services and domain.Core (src/): the same for every countryroutesprotocol · API · MCPmapcacheregistrationContract: CountryConfig · content · CountryServicescountries/czLanguagecs-CZURLs/mista, /dokumentaceData sourcesČSÚ, ČÚZK, cities…Servicesgeocoder, boundaries, regionsDomaindatovamapa.czcountries/skLanguagesk-SKURLs/miesta, /dokumentaciaData sourcesŠÚ SR, ÚGKK, cities…Servicesboundaries, regionsDomaindatovamapa.skA country fills the contract, the core never asks which country it is
Three layers: the core is the same for every country, the contract says what a country supplies, and each country fills it its own way.

What goes where

AreaCore (src/)Country (countries/<id>)
Routes and pagesneutral English routes, layout, componentspublic URLs in the country's language (/mista × /miesta)
Protocol, API, MCPschema, validation, endpoints, tools: the same and in English everywherenothing
Texts for peoplethe Czech source text in a t("…") calltranslation catalog messages.ts and docs in docs/*.md
Identityonly the CountryConfig typeid, language, canonical domain, time zone, regions, map bounds
Data sourcesproxy, adapters, cache, catalogprovider registry and adapters in providers/
Gazetteersearch, municipality and region pagesmunicipalities, regions and boundaries (JSON from the country's own scripts)
Contenthow it is renderedcurated maps, municipality theme blocks, editorial calendar, social profiles
Serviceswhat happens when a service is missinggeocoder, municipality boundaries, regional figures (each optional)
Operationsworker.ts, cache, D1 migrationsits 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
When one worker for all domains is enough

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.

Text
.
├── 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
// 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
// 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

FileContentsCan it be empty?
config.tsidentity, language, domain, map bounds, regions, public pathsno
providers/registry.tsconnected data providers and their adaptersyes
content/maps.ts, place-themes.ts…curated maps, municipality overview blocks, editorial calendar, social profilesyes
content/docs.ts + docs/*.mdpublic documentation in the country's languageno
messages.tstranslations of the core's textsyes, for the source language
content/places.json…gazetteer: municipalities, regions, boundaries (generated by the country's scripts)no
content/outline.jsoncountry outline for the map and links between countriesno
services.tsoptional servicesyes
wrangler.jsoncthe country's worker (only outside the default country)-
Grow the contract carefully

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.

TypeScript
// 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
// 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
// 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"

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
// 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][][][] },
];
Keep big data out of the main bundle

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/app have neutral English names (places, docs). Public URLs reach them through rewrites generated from config.paths.
  • Every link is built with routeHref, never by hand. The same code produces /dokumentace/api in Czechia and /dokumentacia/api in Slovakia.
  • Internal URLs are never served. worker.ts redirects a request for an internal route to the public one, so no page exists under two URLs.
src/lib/paths.ts
// 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.

TypeScript
// 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
// 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();
},
Terminal
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 over

What 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
// 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
// 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 },
};
TypeScript
// 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 offers find_place at 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.

Key takeaways
  • 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
// 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);
CheckWhat it catches
tsc --noEmita country missing a new contract field, or a core that assumes one country's data
check-docs.tsbroken samples and links in the country's docs, a region URL colliding with a route
i18n.ts --checkmissing or unused translations
registry and outlinea 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
// 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 }
}
KeyCzechiaSlovakiaWhy separate
namedatovamapadatovamapa-ska separate worker, versions and logs
d1_databasesdatovamapa-d1datovamapa_skregistrations and approvals belong to one country
namespace_id10012001with the same ID the countries would share a counter
NOTIFY_FROMmail.datovamapa.czmail.datovamapa.ska sender on the country's domain
WORKER_SELF_REFERENCEdatovamapadatovamapa-skOpenNext 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
// 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] : [];
Terminal
npm run deploy                # Czechia: root wrangler.jsonc
COUNTRY=sk npm run deploy     # Slovakia: countries/sk/wrangler.jsonc
Deploy pipeline in six steps: use-country.ts switches the symlink, prebuild writes the browser i18n catalog, next build sets the version, OpenNext builds the worker and assets, Wrangler bundles worker.ts and the deploy switches traffic to the new version. With COUNTRY=sk step 1 points at countries/sk and steps 4 to 6 get --config countries/sk/wrangler.jsonc.1use-country.tsswitches src/country2prebuildi18n catalog for the browser3next buildversion = commit count4OpenNext build.open-next/ (worker + assets)5wrangler bundleworker.ts + .open-next6deploynew version takes the trafficCOUNTRY=sk → countries/sk + --config countries/sk/wrangler.jsonc (steps 4–6)
What one command does. COUNTRY decides where the symlink points and which Wrangler config is used.

The country's domain and links between countries

  • config.origin is 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 have workers_dev: false and run only on their own domains.
  • The map builds links between countries from the countries/index.ts registry: the neighbouring country's name in the current country's language (Intl.DisplayNames) and a link to its origin.

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 in src/, 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 with routeHref and docHref.
  • 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. tsc against the symlink checks only the selected country, so every core change goes through check:countries.
  • Worker config is never copied blindly. The same namespace_id would 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.

Key takeaways
  • 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

Launching your app in several countries?

We design a shared-core architecture and set up each country on its own domain on Cloudflare.

Contact Us