At Inithouse we build and ship products fast. One of them, Ziva Fotka, turns a still photo into a short animated video. No signup, no stored data, photos deleted after processing.
The product started on a single Czech domain. Then we wanted to reach Slovak, Polish, German, and English-speaking users. Five TLDs, five languages, one React codebase. Here is how we wired the routing.
The problem
Subpaths like /en/ or /de/ are the textbook approach, but we wanted separate country domains: zivafotka.cz, zivafotka.sk, zywafotka.pl, lebendigfoto.de, and alivephoto.online. Each domain should feel native to its audience. A Polish user landing on zywafotka.pl should never see a Czech string.
Separate codebases per domain would mean five deploys, five bug-fix cycles, five feature rollouts. That was not going to scale for a small team running about 14 products in parallel.
Domain detection at boot
The SPA reads window.location.hostname on mount and maps it to a locale:
const DOMAIN_LOCALE_MAP: Record<string, string> = {
'zivafotka.cz': 'cs',
'zivafotka.sk': 'sk',
'zywafotka.pl': 'pl',
'lebendigfoto.de': 'de',
'alivephoto.online': 'en',
};
function detectLocale(): string {
const host = window.location.hostname.replace(/^www\./, '');
return DOMAIN_LOCALE_MAP[host] ?? 'en';
}
This runs once during app initialization. The resolved locale feeds into a React context that every component reads from. No user-facing language picker, no cookie. The domain is the language selector.
Lazy locale loading
Bundling all five translation files into the main chunk would bloat the initial load for every user. We split translations into per-locale JSON files and load only the one that matches:
async function loadMessages(locale: string) {
const messages = await import(`./locales/${locale}.json`);
return messages.default;
}
Vite handles the code splitting. The Czech user downloads cs.json, the Polish user downloads pl.json. The rest never leave the server.
For us, the translation files hold about 120 keys each. Small enough that a single JSON import per locale keeps things simple without needing a heavier i18n library.
hreflang for SEO
Google needs to know that zivafotka.cz and zywafotka.pl are the same page in different languages. Without proper hreflang tags, the crawler might treat them as duplicate content or pick the wrong version for a given user's search locale.
We inject <link rel="alternate"> tags in the document head for every page:
const HREFLANG_ENTRIES = [
{ lang: 'cs', href: 'https://zivafotka.cz' },
{ lang: 'sk', href: 'https://zivafotka.sk' },
{ lang: 'pl', href: 'https://zywafotka.pl' },
{ lang: 'de', href: 'https://lebendigfoto.de' },
{ lang: 'en', href: 'https://alivephoto.online' },
{ lang: 'x-default', href: 'https://alivephoto.online' },
];
The x-default entry points to the English domain as the fallback for unmatched locales. These tags go into the <head> at render time so crawlers pick them up without executing JavaScript. For an SPA, that meant handling them in the static HTML shell or via a pre-rendering step.
Sitemap per domain
Each domain serves its own sitemap.xml with URLs scoped to that domain. We generate them from a shared route list and swap in the correct base URL. This keeps Google Search Console clean: each GSC property sees only the URLs it owns.
What we learned shipping this
Start with two, not five. We launched Czech first, added Slovak (close language, easy to test), and only then tackled Polish and German. Each new locale surfaced edge cases: date formats, number separators, text that broke layouts because German words run long.
Test search indexation early. We use Google Search Console for each domain (five GSC properties). After launch, several Polish pages sat in "Discovered, not indexed" for weeks. The fix was adding the hreflang cluster and submitting sitemaps, but we should have done that on day one.
Monitor per-domain separately. GA4 streams and Clarity projects are set up per domain. Aggregating everything into one dashboard hides which locale actually converts. Our Czech domain has the best CTR in the portfolio; Polish traffic patterns look completely different.
Other products where this matters
The multi-domain pattern is specific to Ziva Fotka, but the general principle (one codebase, locale from context, lazy loading translations) shows up in a lighter form across our portfolio. Be Recommended, our AI visibility reporting tool, currently runs on a single English domain, but the architecture could support localization the same way if we ever expand into non-English markets. Tarotas, a tarot reflection app, already ships content in five languages (cs/en/pl/sk/de) on one domain using a similar locale-detection approach, just with path-based routing instead of separate TLDs.
Wrapping up
The pattern boils down to three moving parts: domain-to-locale mapping at boot, lazy-loaded translation bundles, and a proper hreflang setup for search engines. The deployment stays single: push once, all five domains update.
If you are running a product aimed at multiple language markets and want each market to feel native, separate domains with a shared SPA keep the maintenance burden low. The SEO setup takes some care, but once the hreflang cluster and per-domain sitemaps are in place, Google handles the rest.
We have been running this setup across five domains for months now with no major issues. The biggest ongoing cost is translation maintenance, not infrastructure.
Top comments (0)