Make Content Bilingual With i18n
Add per-locale translations to an existing content model using a JSONB container backend, with fallbacks, a backfill of legacy data, and a stable non-translated slug. Prove zero N+1 when reading translations in a collection.
Fork or clone it (Ruby/Rails, Python/FastAPI or TypeScript) and make the failing tests pass.
Problem
Your platform has a content model — call it `Article` — with `title`, `summary`, and `body` stored as plain text columns. It was born monolingual. Now the product needs to serve the same article in more than one language (say `en` and `pt-BR`), showing each reader the text for **their** locale. The naive fix — duplicate the whole record per language — breaks fast: it forks your primary keys, your slugs, your associations, and your analytics. A translated article is still **one** article; only some of its fields change per language. In this lab you will retrofit translations onto an existing model without rewriting it. You will: - Store translations in a single JSONB `translations` column shaped as `{ "<locale>": { "<field>": value } }`, indexed for lookup. - Declare exactly which attributes translate (`title`, `summary`, `body`) and resolve them by the current locale through the ordinary accessor (`article.title`). - Backfill the existing monolingual text into the source locale so nothing is lost. - Configure a fallback chain so a reader **never** sees an empty field when a translation is missing. - Keep the `slug` **out** of the translations — it stays a physical, unique column, generated once. This is the trap that sinks most first attempts. - Let editors author one locale at a time without wiping the other. The catch you must respect throughout: **the URL must not move when the language changes.** A slug that varies per locale silently breaks every route, bookmark, sitemap entry, and inbound link you have.
Objectives
- Add a JSONB
translationscolumn (with a GIN index) to an existing content model and
backfill the current text into the source locale. - Declare the translatable attributes and read/write them by the current locale through
the ordinary accessors. - Configure a fallback chain (
en<->pt-BR) so a missing translation never renders as
an empty field. - Keep the
sluga physical, unique, non-translated column generated once from the
creation-locale title — and explain why translating it breaks URLs. - Author each locale independently: saving one language must not erase the other.
- Prove with tests that separate
en/pt-BRwrites coexist, that fallback fills gaps,
and that reading translations across a collection triggers zero extra queries (no N+1).
Prerequisites
- An existing app with at least one content model (e.g.
Article) that has plain-text
fields, in the language/framework of your choice. - A relational database with JSONB support (PostgreSQL, or an equivalent JSON column
type) and the ability to add a GIN/functional index. - A locale/i18n mechanism that exposes a "current locale" you can set per request
(e.g. from?locale=, anAccept-Languageheader, or the session). - A test runner able to count SQL queries (to assert the absence of N+1).
- Ability to write and run a schema migration and a data backfill against your dev
database.
Translation is a storage-and-resolution problem, not a copywriting one. You need a
place to keep N versions of a field, a rule that picks the right version for the current
reader, and a fallback for when the right version isn't there yet.
This lab uses the container backend: one JSONB column per record holds all
translations for that record, shaped as { "<locale>": { "<field>": value } }. Because
the JSON travels inside the row itself, loading the record loads every translation with
it — no join, no per-record follow-up query, no N+1.
articles.translations =
{
"en": { "title": "Getting Started", "summary": "...", "body": "..." },
"pt-BR": { "title": "Primeiros Passos", "summary": "...", "body": "..." }
}
The main alternative is a separate translations table (one row per
record × locale × field, key/value style). It is more normalized and lets you query or
index individual translations at the database level, but it reintroduces the join and the
N+1 risk you just removed, and it complicates writes. For a bounded set of locales read
together with the record — the common CMS case — the JSONB container wins on simplicity
and read performance. Reach for the separate table when locales are many, sparse, or must
be queried independently.
Two invariants hold this together and you will implement both explicitly:
-
The slug is not a translation. It is identity, not content. Generate it once,
store it physically, keep it unique. If it changed per locale, the same article would
answer to different URLs and every existing link would rot. -
Writing one locale must not touch another. Editing forms must read the raw value
of the active locale (fallback OFF), so saving never copies a fallback into a locale
that had none.
Work through the six steps in order. Each builds on the previous, and the last defines
exactly what "done" means.
Steps
-
Migration: JSONB translations column + GIN index + backfill
Add the container column, index it, and move the existing monolingual text into the
source locale so no content is lost.Pick the source locale that matches your current data (this lab assumes
pt-BR— the
language the legacy rows were written in).-- 1. Add the JSONB container, defaulting to an empty object. ALTER TABLE articles ADD COLUMN translations JSONB NOT NULL DEFAULT '{}'::jsonb; -- 2. Index it so lookups into the JSON are fast. CREATE INDEX index_articles_on_translations ON articles USING GIN (translations); -- 3. Backfill: fold each legacy text column into the source locale. UPDATE articles SET translations = jsonb_build_object( 'pt-BR', jsonb_strip_nulls(jsonb_build_object( 'title', title, 'summary', summary, 'body', body )) ) WHERE translations = '{}'::jsonb;Once the data lives in
translations, the legacy text columns are no longer the
source of truth. Make them nullable so new records aren't forced to double-write, but
do not drop them yet — keep them one release as a safety net you can compare
against.ALTER TABLE articles ALTER COLUMN title DROP NOT NULL, ALTER COLUMN summary DROP NOT NULL, ALTER COLUMN body DROP NOT NULL;Make the migration reversible (drop the index and column on rollback), and verify the
backfill: every row that had a title should now havetranslations->'pt-BR'->>'title'
populated. -
Declare translatable fields and resolve by locale
Tell the model which attributes are translated and route their accessors through the
current locale, reading from and writing into the JSONB container.Declare the set explicitly — only
title,summary, andbodytranslate;slug,
timestamps, and foreign keys do not.# pseudocode class Article translates :title, :summary, :body # backend: :jsonb, column: :translations # The reader resolves against the current locale: # article.title -> translations[Current.locale]["title"] # The writer targets the current locale only: # article.title = x -> translations[Current.locale]["title"] = x endIf your framework has a mature i18n/translations library (e.g. a container/JSONB
backend plugin), lean on it rather than hand-rolling. If not, the accessor is a thin
wrapper:def read_translated(field) locale = Current.locale (translations[locale] || {})[field] end def write_translated(field, value) self.translations = translations.merge( locale => (translations[Current.locale] || {}).merge(field => value) ) endVerify by hand: set the locale to
pt-BR, readarticle.title, and confirm you get
the backfilled Portuguese title; set it toenand confirm you getnil/blank for
now (you have no English yet — the next step fixes the display of that gap). -
Configure fallback so a field is never empty
A reader on
enlooking at an article that only haspt-BRtext must still see
something — the Portuguese, not a blank. Configure a fallback chain so the reader
resolves to the next-best locale when its own is missing.# pseudocode — fallbacks are bidirectional here i18n.fallbacks = { "en" => ["en", "pt-BR"], # en missing -> try pt-BR "pt-BR" => ["pt-BR", "en"] # pt-BR missing -> try en }Now the accessor walks the chain instead of returning the first miss:
def read_translated(field) fallback_chain(Current.locale).each do |locale| value = (translations[locale] || {})[field] return value if value.present? end nil endTwo rules keep fallback from causing surprises:
- Fallback is a read-time, display-only concern. It must never be persisted — the
stored JSON forenstays empty until someone actually writes English. - Treat blank (
"") the same as missing, so an empty string doesn't shadow a good
fallback value.
Verify: with only
pt-BRpopulated, readingarticle.titleunder localeennow
returns the Portuguese title. Add an English title, and the same read returns the
English one — the fallback yields as soon as the real translation exists. - Fallback is a read-time, display-only concern. It must never be persisted — the
-
Keep the slug stable and physical (the critical trap)
This is the step people get wrong. It is tempting to make
slugtranslatable so the
URL reads nicely in each language. Do not. The slug is the record's public
identity; it must be one value, stable across every locale.Why it breaks if you translate it:
- The same article would resolve to
/articles/getting-startedin English and
/articles/primeiros-passosin Portuguese — two URLs for one resource. - Every bookmark, sitemap entry, canonical tag, and inbound link points at exactly
one of them; switching locale would 404 or silently show the wrong thing. - Route lookups by slug become locale-dependent, so a shared link opened under a
different locale can't be found at all.
Keep
sluga plain, unique, indexed column — not insidetranslations. Generate
it once at creation from the title in whatever locale the record was first written in,
then freeze it.# pseudocode before_create :assign_slug def assign_slug # read the title in the creation locale, WITHOUT fallback, # so the slug reflects the real source text source = read_raw(:title, Current.locale) self.slug = ensure_unique(parameterize(source)) endDo not regenerate the slug when a translation is added or the title is edited later —
that would move the URL, which is exactly what we are preventing. If a human truly
must change a slug, treat it as a deliberate action with a redirect from the old one.Verify: create an article in
pt-BR, note its slug, then add anentranslation with
a different title. The slug must be unchanged, and the article must still resolve by
that single slug under both locales. - The same article would resolve to
-
Per-locale authoring without wiping the other language
Editors work one language at a time. The form must save into the active locale and
leave every other locale exactly as it was. The subtle bug here is fallback leaking
into writes.Select the editing locale explicitly — commonly
?locale=pt-BRon the form URL — and
set it for the request:# pseudocode — controller def edit Current.locale = params[:locale] || default_locale @article = Article.find_by!(slug: params[:slug]) endThe trap: if the form pre-fills fields using the fallback reader, an editor
opening the emptyenform would see thept-BRtext (via fallback), and saving would
copy that Portuguese intoen— silently corrupting the "missing translation" signal.So the form must pre-fill from the raw value of the active locale, fallback OFF:
# pseudocode — form value value = article.read_raw(:title, Current.locale) # no fallback in the formOn save, write only the active locale's fields; merge, never replace, the JSON:
# pseudocode — update Current.locale = params[:locale] article.title = form[:title] # writes into translations[locale] article.summary = form[:summary] article.body = form[:body] article.save # other locales in translations are untouchedVerify: fill the
pt-BRform, save; open theenform (it should be blank, not
pre-filled with Portuguese), fill and save it; reload and confirm both locales now hold
their own distinct text and neither overwrote the other. -
Tests: separate writes, fallback, stable slug, and zero N+1
Lock the behavior with tests. Four properties matter, and the N+1 proof is the one
that justifies the JSONB choice.1. Separate per-locale writes coexist.
with_locale("pt-BR") { article.update!(title: "Primeiros Passos") } with_locale("en") { article.update!(title: "Getting Started") } assert_equal "Primeiros Passos", with_locale("pt-BR") { article.reload.title } assert_equal "Getting Started", with_locale("en") { article.reload.title }2. Fallback fills a gap, and does not persist.
article = create_article_with(only: { "pt-BR" => { title: "Só PT" } }) assert_equal "Só PT", with_locale("en") { article.title } # fallback shows PT assert_nil article.read_raw(:title, "en") # but en stays empty3. The slug is stable across translation.
article = with_locale("pt-BR") { create_article(title: "Primeiros Passos") } original_slug = article.slug with_locale("en") { article.update!(title: "Getting Started") } assert_equal original_slug, article.reload.slug4. Zero N+1 when reading translations across a collection.
Load a batch of articles, read a translated field on each, and assert the query count
stays flat — with the JSONB container the translations came along with the rows.create_list_of_articles(10) assert_queries(1) do Article.all.each { |a| a.title } # no extra query per record endSubmission criterion: a translatable content model where all four hold —
enand
pt-BRare written and read independently, fallback prevents any empty field without
being persisted, the slug is generated once and never changes when a translation is
added, and reading a translated field across a collection of N records issues no
per-record query (a flat, N-independent query count). When those tests pass, the lab is
done.