Platform · 90 min

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.

Get the starter project

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

Prerequisites

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:

  1. 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.
  2. 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

  1. 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 have translations->'pt-BR'->>'title'
    populated.

  2. 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, and body translate; 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
    end
    

    If 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)
      )
    end
    

    Verify by hand: set the locale to pt-BR, read article.title, and confirm you get
    the backfilled Portuguese title; set it to en and confirm you get nil/blank for
    now (you have no English yet — the next step fixes the display of that gap).

  3. Configure fallback so a field is never empty

    A reader on en looking at an article that only has pt-BR text 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
    end
    

    Two rules keep fallback from causing surprises:

    • Fallback is a read-time, display-only concern. It must never be persisted — the
      stored JSON for en stays 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-BR populated, reading article.title under locale en now
    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.

  4. Keep the slug stable and physical (the critical trap)

    This is the step people get wrong. It is tempting to make slug translatable 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-started in English and
      /articles/primeiros-passos in 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 slug a plain, unique, indexed column — not inside translations. 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))
    end
    

    Do 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 an en translation with
    a different title. The slug must be unchanged, and the article must still resolve by
    that single slug under both locales.

  5. 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-BR on 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])
    end
    

    The trap: if the form pre-fills fields using the fallback reader, an editor
    opening the empty en form would see the pt-BR text (via fallback), and saving would
    copy that Portuguese into en — 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 form
    

    On 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 untouched
    

    Verify: fill the pt-BR form, save; open the en form (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.

  6. 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 empty
    

    3. 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.slug
    

    4. 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
    end
    

    Submission criterion: a translatable content model where all four hold — en and
    pt-BR are 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.