
Why we model translations as rows, not JSON

Storing translations in a JSON column looks like the pragmatic choice. One column, no joins, no migrations when a locale is added. The bill arrives later.
What breaks
Reporting on translation coverage becomes a full table scan with JSON path expressions. Uniqueness on slugs per locale cannot be enforced by the database. Partial indexes on published, localized content are unavailable. And every consumer must agree on the blob shape forever.
What we do instead
Every translatable entity has a sibling table keyed on (entity_id, locale) with a second unique constraint on (locale, slug). Adding Japanese is an insert into languages — no migration, no deploy.
Related articles

Core Web Vitals budgets that survive the second sprint
A launch-day Lighthouse score is a snapshot. A CI budget is a guarantee.

Shipping a multilingual site without duplicating your codebase
Locale-prefixed routing, hreflang, canonical URLs and per-locale slugs — handled once, in the router.