How to model multilingual content without duplicating types per locale
TL;DR
Model multilingual content as one type with locale-aware entries, not a separate content type per language. Add a fallback chain, use locale-prefixed routing, and fetch each locale at build time to avoid a network round trip on every language switch.
You don't need a article, article_fr, and article_de for every content type you have. Model it once: define the content type a single time, and make locale a property of the entry rather than the schema. Each language gets its own entry (same type, same fields, same validation) linked back to the others by a stable ID. That one decision separates a multilingual content model that scales to a tenth language from one that needs a schema migration every time marketing adds a market.
Below: why the type-per-language pattern breaks down, what a locale-aware model looks like (with a concrete example), how to handle fallbacks and partial translations without special-casing them, and how to avoid the network round-trip that makes locale-switching feel slow.
The anti-pattern: a content type per language
It's an easy trap to fall into, especially if your CMS didn't have native localization when you started. You need an English blog post and a French one, so you duplicate the BlogPost type into BlogPost_fr. Then Spanish. Then German. Six months later you have four content types that are really one content type, copy-pasted.
It looks harmless at first. The fields match, everything works. Then it compounds:
- Every schema change is now four schema changes. Add a
readingTimefield and you editBlogPost,BlogPost_fr,BlogPost_es,BlogPost_deseparately, and it's easy to miss one. Sooner or later the "identical" types drift apart. - There's no link between translations. Nothing in the model says "this French entry is the translation of that English one." You can't ask the API "give me this piece in every language it exists in," generate
hreflangtags programmatically, or show a language switcher without hand-maintained mapping tables on the frontend. - The content-type list balloons. Ten real content types across five languages is fifty types in your schema: fifty things to document, fifty things a new developer has to learn are only ten.
- API consumers branch on type name instead of a parameter. Fetching a page becomes
if (locale === 'fr') fetch('/blog_fr/...')scattered through the frontend, instead of one code path with a locale argument.
This is a well-documented pain point: teams reach for per-language duplication because it's the first thing that works, and only feel the cost once they're maintaining it at scale.
A locale-aware model: one type, per-locale entries, shared structure
The fix is structural. Define the content type exactly once: its fields, field types, and validation rules live in one place. Then, instead of one entry per piece of content, you have one entry per (piece of content × locale), and every one of those entries points to the same type.
What ties the translations together is a stable identifier that's independent of locale: a translation group, a reference ID, or in some systems a shared slug. A blog post type might look like this:
Content type: Article (defined once)
fields:
title: string
slug: string
body: richtext
locale: string # e.g. "en-US", "fr-FR", "ro-RO"
translation_of: reference (self) # groups locale variants together
published_at: datetime And the entries for one real article, in three languages:
[
{ "id": "art_9f2", "locale": "en-US", "title": "Shipping in Q3", "translation_of": "art_9f2" },
{ "id": "art_9f2_fr", "locale": "fr-FR", "title": "Livraison au T3", "translation_of": "art_9f2" },
{ "id": "art_9f2_ro", "locale": "ro-RO", "title": "Livrare în T3", "translation_of": "art_9f2" }
]One type, three entries. A single query parameter (?locale=fr-FR) picks the right one, and translation_of gives you the group whenever you need to render a language switcher or generate hreflang alternates.
Two variants of this are worth naming, because CMSs differ on which they default to:
- Entry-per-locale (shown above) fits content that's substantially rewritten per language: blog posts, marketing pages, long-form copy. Each locale gets a full, independently editable entry.
- Field-level localization uses a single entry where individual fields carry per-locale values (
title: { en: "...", fr: "..." }). This fits content where most fields are shared and only a few need translating: a product's price and SKU (shared) versus its description (localized). It's less common for full-page content because you lose independent publishing state per locale (you can't readily "publish the French version now, English later").
Most content-heavy sites want entry-per-locale for pages and articles, and can reserve field-level localization for structured data where only a couple of fields actually differ.
Fallbacks, partial translations, and routing
Multilingual content is never all-or-nothing. You'll ship a new language with the homepage translated and the blog archive not, or a content update that lands in the source language before translators get to it. Two things need to be true for that to not break the site.
Fallbacks belong in the delivery layer. Don't create a placeholder entry or a special "untranslated" type in the content model. Instead, configure a fallback chain per locale: commonly the more specific locale falls back to a broader one, which falls back to the site's default: fr-CA → fr-FR → en-US. When a request comes in for a locale/entry combination that doesn't exist, the API resolves it against that chain and returns the nearest available version instead of a 404 or a blank field. Your frontend doesn't need to know whether what it received is a real translation or a fallback, though it's worth exposing a translation_status field (translated / fallback / machine) so the frontend can show a "this page isn't translated yet" notice if you want one.
Partial translations are normal. A rich-text field can be translated while a related field (an author bio, a category label) still resolves via fallback. Because both live on the same entry type, this doesn't need special modeling: each field either has a value in that locale or falls back independently. What it does need is a way for editors to see translation status at a glance (which fields are outstanding) rather than discovering gaps in production.
Routing follows the same locale-first logic. Locale-prefixed paths (/en/blog/post, /fr/blog/post) are the most common and the easiest to reason about for SEO: each locale gets its own crawlable URL, and you emit hreflang alternates linking the group. Subdomains (fr.example.com) or ccTLDs work too, at higher infrastructure cost. Avoid inferring locale from an Accept-Language header with no URL signal. That's invisible to search engines and impossible to link to directly.
The same fallback-and-partial-translation problem shows up outside the CMS layer too, in translation-workflow and frontend-i18n tooling. Keep that in mind if your gap is in the translation pipeline rather than the content model.
Delivery: avoid the per-switch round trip
Getting the model right solves half the problem. The other half is what happens when a visitor switches languages, and this is where a lot of implementations get slow. The naive pattern: a user clicks the language switcher, the frontend fires a fresh API call (or several, one each for page content, navigation, and global settings) to refetch everything in the new locale, and the visitor waits through a round trip that a normal page navigation wouldn't have.
A few things fix this, and you make them once rather than per page:
- Resolve locale in one request. If your API design forces separate calls for the page body, the navigation menu, and shared UI strings per locale switch, you've turned one navigation into three network round trips. A single request that returns the full page payload for a given locale (content plus the references it needs) avoids that.
- Fetch at build time for statically generated sites. If you're on an SSG-first framework (Astro, Next.js static export, SvelteKit prerendering), a locale switch never needs to hit the network at runtime: pre-render every locale's routes at build time from the CMS, and switching languages is a link to an already-built page. This is the strongest lever available, and it removes the round trip rather than optimizing it.
- Cache per-locale responses at the CDN edge, keyed on locale, so repeat requests for the same locale/page combination don't reach the origin API at all.
- Don't couple locale-switching to a full page remount. Even with fast delivery, re-fetching and re-rendering everything (including content that doesn't change across locales, like layout chrome) adds perceived latency the model itself didn't require.
The type-per-language anti-pattern and the round-trip problem are related, even though one's a schema issue and the other's a delivery issue: teams that duplicated types often also end up duplicating fetch logic per locale, because the type name is baked into the query. Fixing the model (one type, locale as a query parameter) is usually what makes the delivery fix possible in the first place.
How Adapto CMS handles locales
Check Adapto's data model docs for how types, fields, and references work and the exact localization mechanics, and the available languages API for which locales are enabled and how content resolves by locale on a given tenant.
Adapto itself uses field-level localization: a piece of content is one entry, and the fields that need translating carry a value per locale on that same entry, rather than spawning a separate entry per language. A piece and its translations are authored together. You create the full set at once through the adapto CLI, and each translation's translation_of_id links it back to its source. Reading a specific locale is a query parameter, not a different endpoint or type: add ?language=<code> to the request. That's the tradeoff named above playing out in practice: field-level localization ships translations as a set rather than independently per locale, so there's no "publish English now, French next week" workflow here.
Translations are unlimited on every tier, including the free evaluation tier, with no per-locale fee and no locale cap. As of this writing, this site's own tenant has only en-US enabled. The mechanics are the same regardless of how many locales a given tenant turns on.
If you're evaluating a CMS for a multilingual project rather than modeling content on one you've already picked, what to check when picking a CMS for multilingual sites covers the pricing and locale-cap side of the decision. Some vendors handle the model well but cap or meter locales by tier.
FAQ
How do I model multilingual content without duplicating types?
Define the content type once (its fields, validation, and structure) and treat locale as a property of each entry, not of the schema. Every article, page, or product gets one localized entry per language, all sharing the same type and a stable ID that groups the translations together. You query the API by locale (for example, ?locale=fr-FR) instead of by a locale-specific type name, so adding a language means adding entries, never redefining or duplicating the schema.
How do fallbacks work for partial translations?
A well-behaved multilingual API resolves missing translations at delivery time, not by leaving a content gap: if a locale's entry, or a specific field within it, isn't translated yet, the API serves a configured fallback locale (usually the site's default or source language) instead of a 404 or an empty field. Some CMSs also expose a translation-status flag per entry or field so the frontend can mark which passages are fallback content rather than reviewed translations, either silently or with a visible banner.