Introduction
Building a global product means serving users in dozens of languages, and how you store that localized content directly shapes your query performance, maintenance burden, and ability to scale. Localization data in MongoDB: what developers should know starts with a fundamental choice—whether to embed translations, reference them separately, or use a hybrid approach. We've learned through production experience that the embedded approach with careful indexing and fallback strategies works well for most teams, but there are tradeoffs worth understanding.
In this article, we'll walk through the practical patterns we use to store and query multi-language content, from designing the document structure to handling missing translations gracefully.
Embed translations keyed by locale
The most straightforward approach is to embed all translations directly into your main document, using the locale code as the key. This keeps related data together, reduces joins, and makes it simple to fetch a complete product or article in a single query.
Here's a typical structure for an e-commerce product:
{
_id: ObjectId("..."),
sku: "WIDGET-001",
price: 29.99,
names: {
en: "Blue Widget",
es: "Widget Azul",
fr: "Widget Bleu",
de: "Blaues Widget",
ja: "ブルーウィジェット"
},
descriptions: {
en: "A reliable widget for everyday use.",
es: "Un widget confiable para uso diario.",
fr: "Un widget fiable pour un usage quotidien.",
de: "Ein zuverlässiges Widget für den täglichen Gebrauch.",
ja: "日常使用に信頼できるウィジェット。"
},
createdAt: ISODate("2024-01-15T10:00:00Z"),
updatedAt: ISODate("2024-01-15T10:00:00Z")
}
When a user requests the product, you pass their preferred locale and extract just the translation they need:
async function getProductForLocale(sku, locale) {
const product = await db.collection('products').findOne({ sku });
if (!product) return null;
return {
_id: product._id,
sku: product.sku,
price: product.price,
name: product.names[locale],
description: product.descriptions[locale],
createdAt: product.createdAt
};
}
This pattern scales well because all translations live in one document. There's no network round-trip to join translation tables, and MongoDB's document-oriented model is perfectly suited to this structure. The downside is that your documents grow with each new locale, so you need to be intentional about which languages you support.
Fallback to a default locale
In production, you'll encounter requests for locales you haven't translated yet, or users whose browser sends a locale variant (like en-GB) when you only have en. Graceful fallback prevents your API from returning null and delivers a better user experience.
A simple fallback chain might look like this:
function getTranslation(product, fieldName, locale, defaultLocale = 'en') {
// Try exact match first
if (product[fieldName]?.[locale]) {
return product[fieldName][locale];
}
// Try language-only fallback (en from en-GB)
const languageCode = locale.split('-')[0];
if (languageCode !== locale && product[fieldName]?.[languageCode]) {
return product[fieldName][languageCode];
}
// Fall back to default
if (product[fieldName]?.[defaultLocale]) {
return product[fieldName][defaultLocale];
}
// Last resort: return first available
return product[fieldName][Object.keys(product[fieldName])[0]] || null;
}
async function getProductForLocale(sku, locale) {
const product = await db.collection('products').findOne({ sku });
if (!product) return null;
return {
_id: product._id,
sku: product.sku,
price: product.price,
name: getTranslation(product, 'names', locale),
description: getTranslation(product, 'descriptions', locale),
createdAt: product.createdAt
};
}
This approach handles several real-world scenarios. A user with en-GB locale will get English content if it exists. A user with an unsupported locale like zh-TW will fall back to English. And if English is missing (unlikely but possible during data migration), you'll still get something rather than an error.
Encoding this logic in the application layer gives you flexibility to adjust fallback priorities per region or per user. Some teams choose to handle it in MongoDB aggregation, but we find that application-level logic is clearer and easier to test.
Index the fields you search
Once you're querying for specific locales, indexing becomes critical. If you're searching for products by name in a particular language, you need an index that covers that search pattern.
Consider this scenario: your admin dashboard has a search box that filters products by their Spanish name. Without an index, MongoDB scans every document:
// Creates a COLLSCAN without an index
db.products.find({ "names.es": "Widget Azul" })
Create a targeted index:
db.products.createIndex({ "names.es": 1 })
If you search across multiple locales, you might want a compound index or separate indexes for each locale you query frequently:
// Separate indexes for each locale you search by
db.products.createIndex({ "names.en": 1 })
db.products.createIndex({ "names.es": 1 })
db.products.createIndex({ "names.fr": 1 })
// Or a wildcard index if you query many locales unpredictably
db.products.createIndex({ "names.$**": 1 })
For text search across all translations, a text index is even better:
db.products.createIndex({
"names.en": "text",
"names.es": "text",
"names.fr": "text",
"descriptions.en": "text",
"descriptions.es": "text",
"descriptions.fr": "text"
})
// Query it
db.products.find({ $text: { $search: "blue widget" } })
Without indexes, every search query becomes a performance problem as your data grows. With them, response times stay predictable even with millions of documents.
Keep translations bounded
Embedding all translations in one document is convenient, but it can lead to bloated documents if you're not careful. MongoDB has a 16 MB document size limit, and while most localization data won't hit that, it's worth being intentional about what you store.
Set a reasonable cap on the number of locales you support simultaneously:
const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de', 'ja', 'pt', 'zh', 'ru']
async function updateProductTranslations(sku, updates) {
const sanitized = {};
for (const [field, translations] of Object.entries(updates)) {
sanitized[field] = {};
for (const [locale, value] of Object.entries(translations)) {
if (SUPPORTED_LOCALES.includes(locale) && value && value.length > 0) {
sanitized[field][locale] = value;
}
}
}
return db.collection('products').updateOne(
{ sku },
{ $set: { names: sanitized.names, descriptions: sanitized.descriptions } }
);
}
If you need to support dozens of languages or have very long descriptions, consider a separate collection for translations and reference them from your main document:
// products collection
{
_id: ObjectId("..."),
sku: "WIDGET-001",
price: 29.99,
translationsId: ObjectId("...")
}
// translations collection
{
_id: ObjectId("..."),
names: { en: "Blue Widget", es: "Widget Azul", ... },
descriptions: { en: "A reliable widget...", es: "Un widget confiable...", ... }
}
This trade-off—fetching from two collections instead of one—is worth considering only if your embedded translations are genuinely problematic. For most projects, keeping them embedded and bounded to 5–10 locales is the right call.
Common mistakes
Storing translations as an array instead of a keyed object. If you structure translations as [{ locale: "en", name: "Widget" }, { locale: "es", name: "Widget Azul" }], every query becomes harder because you have to filter the array. Keyed objects like { en: "Widget", es: "Widget Azul" } are faster and cleaner.
Forgetting to validate locale codes. Users or external systems can send invalid locales. If you don't validate them, your fallback logic might silently fail or return null. Always check that incoming locales are in your supported list before processing.
Missing indexes on translated fields. We've seen teams build features that query by name or description in specific locales, then wonder why response times spike once production data grows. Index the translated fields you actually search on, not just the primary document fields.
Inconsistent translation updates. If you update the English name but forget to update Spanish, users get confused. Use transactions or application-level logic to ensure translations stay synchronized during updates.
Not handling missing translations in the application. If you assume every locale exists in every field and the data doesn't match, your API crashes. Always use a fallback function that gracefully returns something sensible.
Key takeaways
- Embed translations keyed by locale (like
names: { en: "...", es: "...", fr: "..." }) for simplicity and performance; it keeps related data together and avoids joins. - Implement a multi-level fallback chain that tries exact match, language-only fallback, then a default locale, so users always get content in some language.
- Index the translated fields you search on—whether that's
names.esfor Spanish searches or a text index across all locales for full-text search. - Keep the number of embedded locales bounded (5–10 is typical) and set a maximum field length to avoid bloating documents and hitting MongoDB's size limits.
- Validate locale codes at the application boundary, and never assume a translation exists; always provide sensible fallbacks in your query layer.






Comments
Loading comments…