Bilingual from the schema — why i18n cannot be a plugin
Bilingual from the schema
There are two ways for software to speak two languages, and they are so different they deserve separate names.
The first is interface translation: the buttons and labels change language, but the data — the posts, the emails, the pages — stays in one language only. The second is content bilingualism: the system understands that the same post, the same email, the same landing page exist in per-language versions, and treats each version as a first-class citizen.
Most tools do the first and call it "i18n support." LaunchWithAgency does the second, and it does it in the place where it actually needs to be done: in the database schema. This article is about why that difference is not a detail, and why i18n added later never catches up with i18n designed from the start.
The symptom: the locale column
Open any of the suite's core tables and you find the same column. In blog_posts: locale, defaulting to pt. In email_templates: locale, part of the template's identity. In landing_pages: locale, and — note this — the table's uniqueness constraint is over (ownerId, slug, locale), not just (ownerId, slug). In newsletter_subscribers: locale, storing the language of each individual subscriber.
That repeated column is no accident. It is a decision made once, at the foundation, and kept with discipline across the whole schema: language is an attribute of the data, not a setting of the account.
The difference is enormous. If language were an account setting — "this account is in Portuguese" — then content would be monolingual by construction, and speaking English would require a second account, or a plugin, or a hack. Because language is a column in the row, every post, every template, every page carries its own language. PT and EN don't compete for one configuration slot; they coexist as neighboring rows in the same table.
How two versions share one identity
The most elegant detail of this architecture is how it links the two versions of the same content without merging them.
Take a bilingual blog post. It is, physically, two rows in blog_posts: one with locale: pt, one with locale: en. Two bodies of text, two titles, two SEO descriptions — because translating isn't copying, it is rewriting, and each language deserves its own worked-over text.
But the two rows share the same slug. The slug is the post's conceptual identity — "this is the article about X" — and the locale is the variant. Uniqueness in the database is enforced over the combination of both: you can have my-article/pt and my-article/en coexisting, and the system knows they are the same article in two languages. That is exactly what allows serving the content at /blogs/my-article to a Portuguese visitor and /en/blogs/my-article to an English one, with the URL mirroring the structure and the reader able to switch languages without getting lost.
> Two rows, one slug. The translation isn't a hidden copy nor an extra field — it is a sibling of the original row, with the same identity and its own text.
This site's blog is the living proof: each post is a pair of Markdown files — name.md in Portuguese, name.en.md in English — both declaring the same slug in the frontmatter. The file you are reading has a Portuguese sibling. The file convention mirrors the database convention exactly.
Why i18n cannot be a plugin
Here is the central thesis, and it is a claim about the timing of the decision.
When software is born monolingual, language is an assumption that seeps in everywhere: into the primary keys, the URLs, the indexes, the queries, the uniqueness rules, the logic for "fetch the post named such." Each of those assumptions was written assuming there is one piece of content per slug. Adding a second language later means hunting down and revising every one of those assumptions — and one escapee is enough for the bilingualism to leak: two languages fighting over the same slug, a URL that doesn't know which version to serve, an index that treats translations as duplicates.
That is why i18n added later almost always looks like a patch. It isn't incompetence on the patcher's part — it is that the monolingual assumption is too widespread to be fully torn out. The translation plugin is eternally fighting a foundation that assumes one language.
LaunchWithAgency avoids that fight not by winning it, but by never starting it. The locale column has been in the schema since the first migration. The uniqueness rules already include language. The URLs already carry the /en prefix. No query ever assumed one piece of content per slug. Bilingualism isn't a feature the suite has — it is a property the suite is, because the foundation was built knowing about it.
Why it matters to the operator
This is not an engineering curiosity. It is a business capability.
An agency serving clients in more than one country, a product team with a market in Latin America and the United States, a founder who wants to publish in Portuguese and reach English readers — all of them need content to be genuinely bilingual, not the interface to have a language picker. They need the post to have an English version with its own SEO, the welcome email to go out in the subscriber's language, the landing page to exist in both languages with clean URLs.
LaunchWithAgency delivers that with no plugin, no second account, no hack — because the decision was made in the only place where i18n can truly be made: in the schema, before the first line of content existed. Serious operations cross borders. Serious software has to be born knowing it.