Noma

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 example en, 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-CHdedefault_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:

ConceptIn Noma
Localelocale (string on the entry)
Publish workflowstate (draft, published, …) and published_at
Link across languagestranslation_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_locale and assigns the same translation_group_id as 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=fr

Behavior that trips people up if they skip the docs:

  • The resolver looks up another entry with the same translation_group_id, the requested locale, and the same state as 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 state query (for example state=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:

StrategyNotes
Locale prefix/en/pricing, /fr/pricing — same slug field across locales, different prefix
Localized slugDifferent 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_locale without a group — returns a clear “no translations linked” style response; link or create translations first.
  • Ignoring state when 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 locale in the same translation_group_id.
  • Locales outside the project listtarget_locale must 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\ContentEntrylocale, translation_group_id, translations() / translationGroup()
  • API: App\Http\Controllers\Api\ContentController::showtranslation_locale query 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)