Blog
How to Model Multilingual Content Without Duplicate Entries
March 19, 2026
Teams often start multilingual by copying an English entry into a French entry. That works once. At scale it creates:
- Orphan variants (no machine-readable link between “the same” article in two languages)
- Drift (title updated in one locale, not others)
- Broken routing (two slugs that should be siblings but are unrelated rows)
- Surprise publish gaps (one locale live, another still draft—forgotten)
Noma (the product backed by the Noma Core codebase, nomacms/nomacms-core) avoids that by treating each locale as its own content entry, while tying variants together with a shared translation_group_id. This post describes that model and how to use it without duplicating conceptual content.
Project-level locales
Every project defines:
default_locale— fallback when a locale is omitted (for example on singleton collections)locales— optional list of enabled locale codes for the project (for exampleen,fr,de)
Only locales in that list are valid for new entries and translation targets. Your frontend should still implement fallback chains (for example de-CH → de → default_locale) in application code if you serve regions beyond the exact codes stored in Noma.
How Noma stores multilingual content
Noma uses linked locale variants, not a single document with nested per-locale maps.
Each row in content_entries is one ContentEntry with:
| Concept | In Noma |
|---|---|
| Locale | locale (string on the entry) |
| Publish workflow | state (draft, published, …) and published_at |
| Link across languages | translation_group_id — UUID shared by every variant of the same logical piece of content |
Field data (title, body, slug, etc.) lives in the entry’s field values as usual—per entry, per locale. Slug fields can differ per locale (localized slugs) or stay aligned; the stable key for “same article” is translation_group_id, not the slug.
The ContentEntry model exposes helpers such as:
translations()— other entries in the same group (excluding self)translationGroup()— all entries in the group, including the current one
So “no duplicates” in Noma means: do not create unrelated entries for each language. Either create a translation in the same group, or link an existing entry into the group.
Creating and linking translations (dashboard)
In the Noma app, the content editor supports:
- Create translation — creates a new entry in a
target_localeand assigns the sametranslation_group_idas the source (or creates a group if needed) - Link translation — connects an existing entry (different locale) into the same group; same locale in one group is rejected
- Unlink — removes a variant from the group when appropriate
- Search with
filter_locale— when picking an entry to link, results are scoped to the target locale
You can also AI-translate into a target locale: that creates a draft entry with text fields translated and non-text fields copied as-is—still tied to the same translation group when applicable.
REST API: fetching by locale and resolving a translation
Collection listing and filters are locale-aware (including admin-style filter_locale for search). For public API consumption, the important contract is single-entry read with an optional translation swap.
Get one entry by UUID (collection slug + entry UUID):
GET /api/{collection}/{uuid}Get the linked entry in another locale (same translation_group_id, same collection):
GET /api/{collection}/{uuid}?translation_locale=frBehavior that trips people up if they skip the docs:
- The resolver looks up another entry with the same
translation_group_id, the requestedlocale, and the samestateas the request’s entry resolution (for example default published vs draft). - If the French variant is draft but the API request resolves entries as published, you can get 404 until you pass the matching
statequery (for examplestate=draft) or publish the translation.
So: translation is linked; publish state is per entry. Plan your headless app’s state and preview routes accordingly.
Creating entries via API uses the usual body with locale, state, and data for fields. Imports can carry translation_group_id through CSV so migrations preserve groups.
What Noma does not do for you
Noma gives you identity (translation_group_id) and per-locale rows with a clear API. Your site or app still owns:
- URL strategy (path prefix, localized slug, or both)
- hreflang and canonical URLs per locale
- Fallback when a locale has no published entry (show default locale, 404, or banner)
- RTL and regional formatting in the frontend
Slug and routing strategy (your layer)
Because each locale is its own entry, slug fields are naturally per locale. Common patterns:
| Strategy | Notes |
|---|---|
| Locale prefix | /en/pricing, /fr/pricing — same slug field across locales, different prefix |
| Localized slug | Different slug values per locale — align with redirects and hreflang |
Never use slug alone as the global identifier across locales—use translation_group_id (or entry UUID + locale) in internal systems.
SEO: hreflang and duplicates
Search engines need explicit relationships between URLs. After you resolve entries per locale:
- Emit
hreflang(or equivalent) for alternate URLs - Use one canonical URL per locale
- Avoid accidental duplicates (same language at two URLs)
Where AI fits in Noma
Noma’s AI translation flow targets structured field values and creates a draft in the target locale, which you then review and publish. That keeps variants aligned under the same group without manual copy-paste.
See AI Generation and Translation.
Common pitfalls (with Noma in mind)
- Expecting
translation_localewithout a group — returns a clear “no translations linked” style response; link or create translations first. - Ignoring
statewhen resolving translations — draft vs published must match how you query, or you will see “not found” for an entry that exists only as draft. - Duplicate locales in one group — linking rejects two entries with the same
localein the sametranslation_group_id. - Locales outside the project list —
target_localemust be allowed on the project.
Summary
In Noma, multilingual content without conceptual duplicates means: one translation_group_id per logical item, one ContentEntry per locale, independent state per locale, and API reads that use translation_locale with a clear state contract. Build routing, fallback, and SEO on top of that foundation.
Related pages:
Implementation reference (Noma Core)
For readers maintaining or extending the product:
- Model:
App\Models\ContentEntry—locale,translation_group_id,translations()/translationGroup() - API:
App\Http\Controllers\Api\ContentController::show—translation_localequery parameter - Dashboard: translation link / create / AI flows in
resources/js/pages/Content/ContentForm.tsx - Tests:
tests/Feature/Content/ContentTranslationTest.php,tests/Feature/API/APIContentEdgeCasesTest.php(translation +state)