The localization I skipped
I have a standard operating procedure for new products, and near the top of it is one rule I have never regretted following and just spent weeks regretting breaking. Build localization on day one. Not after the first market, not when the first non-English customer asks. Day one, alongside identity and permissions, before there is a single string in the database to convert.
This time I skipped it. Giant Context was built speaking only English, because I was heads-down on proving the thing worked at all and localization felt like a problem for later. Later arrived, and the realization with it. A product built on an LLM has to get language right, and get it global.
Why a platform is always multilingual
The question of whether to localize has an easy answer for a platform, and the answer is always yes. A single app for a single market can sometimes get away with one language. A platform cannot, because a platform's whole point is to scale past the room it started in. Giant Context builds websites for businesses, and businesses have customers who do not all read English. The moment a French bakery or a Mexican firm wants a site, the product either speaks their language or it does not serve them. I also live in a bilingual country, where serving one language serves half of it.
On anything meant to scale beyond its immediate market, localization is a certainty deferred rather than a feature request that might come, and deferring a certainty is just choosing to pay more for it later. That is why it sits in my standard operating procedure next to role-based access control. Both are foundational, both are miserable to retrofit, and both should shape the schema before the schema has any data in it.
Why retrofitting costs more
Doing it late hurts for a specific reason. Localization is not code you add beside your application. It is a change to the type of your data, and a localized field is a different kind of thing, an object holding one value per language.
A localized field
heading: { en: "Book a table", fr: "Réserver une table", es: "Reservar una mesa"}Every user-facing field in the product has to make that jump, from string to object. Retrofitting means making that change in the database schema, in the API contracts that describe every field, in the generated code that materializes those contracts, in the frontend that renders every field, in the backend that reads them, and in the data that already exists as bare strings.
I cannot tell you what it cost, and that is the honest answer rather than a dodge. There was no ticket to close. Fields kept surfacing that nobody had thought of as user-facing until a second language existed, and each one was a small change in five places. I am still finding them.
Had the decision been made on day one, none of it would have been a migration. The fields would have been objects from the first commit, the generator would have emitted them that way, and there would have been no string data to convert because there would never have been any.
A name is not a title
The decision that makes the rest of it work is deciding, for every field, who reads it.
A post has a name and it has a title. The name is what I call it in the console, what appears in a list of posts, what I search for when I am looking for it. It is a handle, it belongs to whoever operates the platform, and it is a plain string. The title is what a reader sees at the top of the page, and it is a different type entirely.
Two field types, one distinction
// internal, an operator's handlename: string;
// external, what a visitor readstitle: TranslatableString;
type TranslatableString = { [locale: string]: string };Stored as jsonb, so a language is a key and adding one is writing to a key that was not there before. No column per language, no table of translations joined on a locale, no schema change when a fourth language arrives.
Conflating the two is the mistake underneath the mistake. If a post has only a title and that title is translated, then the thing an operator types to find their own post is now a language-dependent object, and searching for it means deciding which language they searched in. Names want to be stable and singular. Titles want to be plural and translated. Once they are separate types, both behave correctly and neither is a special case.
Which languages exist is a project setting rather than a platform constant. A project picks a default language and enables the others it wants, and the rest of the system reads that list: content is authored in the default, the enabled set decides which keys a translatable field is expected to carry, and a project running in one language never pays for the machinery of three.
How the system works
Two surfaces need translating and they are different problems. The interface, which is buttons and labels and menus, is fixed text the platform ships. The content, which is a customer's headings and paragraphs, is data the customer owns. Both run on the same machinery here, because the machinery already existed.
Interface strings are declared where they are used. Each package keeps its own locale files beside its code, and a generation step merges every package's declarations into one file per language that the application loads. So adding a translatable label is adding a line to the file next to the component, not finding your place in a central dictionary that every part of the codebase edits and nobody owns.
Content translation is narrower and needs to be. The system holds an explicit list of which field names carry translatable copy, and only those are ever translated. Everything else passes through byte for byte.
The reason that list is explicit rather than inferred is worth stating plainly. A translator that decides for itself what looks like prose will eventually decide wrongly, and a field that was never meant to be language-dependent, an identifier or a type name or a key some other part of the system matches on, gets rendered into a French version of itself. The failure is not a bad translation. It is a value that no longer matches what reads it.
So the list is derived from the type definitions and checked against them by a test. Add a translatable field to a type without registering its name, and the build fails until the two agree. The declaration and the behaviour cannot drift, for the same reason nothing else in this codebase drifts: one of them is generated from the other.
What the customer gets
Localizing the system meant covering two surfaces at once. The business software, both its interface and its API, and the final product, the customers' websites, socials, emails and everything else the platform generates. Building it into the business software made the entire platform multilingual.
Tied into AI, the customer's own content gets translated on save. A customer writes their homepage in English, saves, and the system generates the French and Spanish versions of that content automatically, filling the other languages of every localized field. They author once. The platform speaks the rest.
Testing for missing translations
A localized system has a failure mode that ordinary code does not. It can be half-translated. A field can have its English value and be silently missing its French one, and nobody notices until a French visitor lands on a page with an English sentence sitting in the middle of it, which reads as broken to exactly the customer localization was supposed to win.
So translation completeness is tested, the same way everything here is tested. A check runs across the codebase, finds every string that is supposed to be translated, compares it against the generated locale files, and reports anything missing in any language and any place the languages have fallen out of parity. If a string exists in English and not in French, the check says so before the code ships, not after a customer finds the gap.
That test is the reason I can trust the feature. Localization is not one decision you make and forget. It is an obligation you have to meet on every new string forever, and the only way to meet an obligation that big is to have a machine check it on every push.
What I would tell myself in December
Build localization on day one, with identity and permissions, as a decision about the shape of your data rather than a feature you will get to. A platform is meant to scale, and it cannot scale without language at the core of it.
Building something like this
I'm Jesse. I build platforms end to end, and I'm open to work. If this is the kind of engineering you need, get in touch.