Since August every language version of a site on my engine was a separate instance: Polish at one address, English on a subdomain, each with its own repository, database and deployment. Today that ended. Two sites were merged, and this page got its English version straight in the new layout — under /en, in the same deployment.
Why
Separate instances were easy to set up and expensive to keep. Every content fix went out twice: two commits, two deployments, two checks. The rule “the English version in the same session” was kept by hand, which means sometimes it was not. Statistics sat in two databases and the report had to be stitched together. One of the versions deployed by hand, because automatic builds needed a click in the dashboard. And a reader logged in on the Polish side became a stranger to the site after switching to English, because the session lives on the host.
What it gives
One push deploys both versions. One database holds both languages — every record about a page has a language column, and things about the material (ratings, comments, votes) were keyed by an entry key above language from the start anyway. One domain means one browser identity, one session and one sitemap for the search engine. Instead of five deployments to watch there are three. The old English addresses redirect with a 301 rule to the new ones, path and parameters included.
How
The folder decides an entry's language: Polish entries stay where they were, English ones live in src/content/en/posts. The address decides a page's language — the /en prefix comes from the version's address in its config, so there is no separate field that could drift away from it.
The harder part was elsewhere. The function returning the config is called in hundreds of places and until now always returned the same thing. Now it has to know which version the request is in. A global variable was out straight away: one Worker isolate handles requests concurrently, and two renders interleave at every await. So language is request state — on the server it rides in AsyncLocalStorage, in the browser it is the prefix in the address bar.
page, server route the path prefix — and only that
server function a header from the browser (shared address, no prefix)
browser location.pathname
sitemap, notifications withLang(code, fn) — for each version in turnThe first idea for the prefix, the router's basepath, did not survive contact with TanStack Start: the server overwrites it on every request with the build-time value. rewrite survived — the router sees paths without the prefix, the browser with it. We checked this with a prototype before writing anything else.
The database got a new views table with a language column and a register of data migrations. The old table was left untouched. The data from the old English databases was moved by a script that stops before its first write if it meets a table it does not know. Every migration first went through a dress rehearsal on a copy of the production database, on a temporary database deleted straight afterwards.
What went wrong
The afternoon ended with four fix releases. Three came from the merge, and each teaches something different.
The migration filled in the language on old events before the column had been created by the module that adds it — but only on the first event write. The dress rehearsal did not catch it, because the data-moving script had created the column earlier. The test checked the order that works and said nothing about the one that actually happened. From now on a migration creates every column it touches itself.
Recordings from the object store under /en returned 404. The router stripped the prefix from what it sees, but the media route read the key from the request address itself. Locally it did not show, because the local store is empty.
The most instructive: the language switcher worked one way. It went to English, and back to Polish — no. The fallback language source for server functions, the Referer header, also decided ordinary pages: clicking “Polski” on an English page carried the English page's address in that header, and the server sent the reader back. The tests used curl, which does not send that header. It came out when clicking by hand in production.
The takeaway
Each of these faults was the same assumption — one deployment, one language — hidden in a different part of the code. None would have been found by a test of a single request, because a single request always knows its language. They were found only by the things a person does: a click, a move, a way back. From today I test the switcher by clicking, several times in a row, both ways.
Where things stand at the end of the day: the engine has 132 releases in 38 days, three deployments and six language versions. The full write-up, with the rejected options and a list of traps, is in the engine's documentation — it will come in handy the next time something like this gets merged.