Most MEAN apps we are asked to internationalize were built English-only and then sold into a second market. The request arrives as "can we add French?" and the honest answer is that translation strings are the easy third of the job. The hard parts are locale-aware sorting and search in MongoDB, dates and money that survive a timezone boundary, and a build/serve setup that does not double your deploy complexity.
This tutorial takes a single-locale MEAN app (Angular 22, Express 5, Mongoose 8 on MongoDB 8, Node 24 LTS) and makes it properly multi-locale: en-US, fr-FR and de-DE. Everything here is production patterns we use on client migrations, not a toy demo.
The plan
- Decide a locale strategy and URL shape before writing code.
- Extract and translate the Angular UI with
@angular/localize. - Localize dates, numbers and currency with built-in pipes and
Intl. - Negotiate the locale on the Express 5 side and localize API-generated text (emails, PDFs, validation errors).
- Store and query translatable content in MongoDB, with collation so sorting and search behave.
- Build, serve and test every locale in CI.
Step 0: pick a locale strategy first
Three decisions cause almost all the rework we see:
URL shape. Use a path prefix: /en/, /fr/, /de/. It is crawlable, cacheable per-URL on a CDN, and unambiguous. Cookie-only or Accept-Language-only locale switching gives every locale the same URL, which wrecks SEO and makes CDN caching a guessing game. Subdomains work but multiply TLS and cookie-scope work for no real benefit.
Build-time vs runtime translation. Angular's native i18n compiles one bundle per locale. You ship dist/browser/en/, dist/browser/fr/, dist/browser/de/. Each user downloads only their language, and there is no translation-loading flash. The cost is a longer build and a redeploy for copy changes. Runtime libraries (Transloco and friends) flip that trade. Our default: native i18n for the application shell, and database-stored content for anything non-developers edit.
Source of truth for content. UI chrome lives in translation files owned by developers. Product names, article bodies and emails live in MongoDB, owned by whoever edits content. Do not mix them; the moment marketing needs a developer to fix a typo, your process is broken.
Step 1: set up Angular i18n
cd client
ng add @angular/localize
Mark translatable text with i18n attributes. Give every message an explicit ID — auto-generated IDs change when the source text changes, which silently invalidates translations.
<h1 i18n="@@tasks.heading">Your tasks</h1>
<p i18n="@@tasks.remaining">
{remaining, plural,
=0 {Nothing left to do}
=1 {One task remaining}
other {{{remaining}} tasks remaining}}
</p>
<button i18n-aria-label="@@tasks.add.aria" aria-label="Add a task" (click)="add()">
<span i18n="@@tasks.add.label">Add</span>
</button>
ICU plural syntax matters more than English speakers expect. French puts 0 in the singular form; Polish and Russian have three or four plural categories. Writing {{ count }} tasks remaining with a naive s suffix cannot be translated correctly into those languages, so use plural blocks everywhere a number meets a noun.
For strings in TypeScript, use the $localize tagged template:
import { signal } from '@angular/core';
const status = signal($localize`:@@sync.offline:You are offline. Changes will sync later.`);
Extract the messages:
ng extract-i18n --output-path src/locale --format=xlf2
That writes src/locale/messages.xlf. Copy it to messages.fr.xlf and messages.de.xlf for translators, or hand the source file to your TMS. Then register the locales in angular.json:
{
"projects": {
"client": {
"i18n": {
"sourceLocale": "en-US",
"locales": {
"fr-FR": { "translation": "src/locale/messages.fr.xlf", "baseHref": "/fr/" },
"de-DE": { "translation": "src/locale/messages.de.xlf", "baseHref": "/de/" }
}
},
"architect": {
"build": {
"options": { "localize": true, "i18nMissingTranslation": "error" }
}
}
}
}
}
i18nMissingTranslation: "error" is the single most useful setting in this file. The default is a warning, and warnings mean untranslated English ships to production. Fail the build instead.
ng build now produces dist/client/browser/en-US/, /fr-FR/ and /de-DE/. During development, serve one at a time:
ng serve --configuration=development,fr
Step 2: dates, numbers and currency
Angular's date, number and currency pipes read LOCALE_ID, which the localized build sets for you. Register the locale data once in app.config.ts if you need non-default formats:
import { ApplicationConfig, LOCALE_ID, inject, provideAppInitializer } from '@angular/core';
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
import localeDe from '@angular/common/locales/de';
registerLocaleData(localeFr);
registerLocaleData(localeDe);
export const appConfig: ApplicationConfig = { providers: [/* ... */] };
Two rules that prevent the bulk of real localization bugs:
Store instants in UTC, render in the user's zone. MongoDB Date fields are UTC milliseconds. Never store a formatted string, and never store "2026-03-29 02:30" as a local wall-clock time, because that instant does not exist in Paris on the day DST starts. Render with {{ task.dueAt | date:'medium':undefined:locale }} and let the browser's zone apply, or pass an explicit IANA zone for business-critical displays:
const fmt = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Europe/Paris' });
Money is an amount plus a currency code, always. Store { amountMinor: 1999, currency: 'EUR' }, never a float and never a pre-formatted string. Localizing the format is presentation; the currency itself is data. {{ amountMinor / 100 | currency:currency:'symbol':'1.2-2':locale }} renders 19,99 € in fr-FR and €19.99 in en-US, and those are both correct.
Watch for date input. A user typing 03/04/2026 means 3 April in Paris and 4 March in New York. Use <input type="date">, which the browser renders in the user's format but always submits as ISO 2026-04-03.
Step 3: locale negotiation in Express 5
The API needs a locale for anything it generates itself: validation messages, transactional emails, invoices, exports. Resolve it once in middleware, with an explicit precedence order.
// api/src/locale.js
const SUPPORTED = ['en-US', 'fr-FR', 'de-DE'];
const DEFAULT = 'en-US';
export function resolveLocale(req, res, next) {
const candidates = [
req.user?.locale, // 1. explicit user preference
req.get('x-locale'), // 2. what the client app is rendering
req.acceptsLanguages(SUPPORTED), // 3. browser negotiation
];
req.locale = candidates.find(c => SUPPORTED.includes(c)) || DEFAULT;
res.set('Content-Language', req.locale);
res.vary('Accept-Language');
next();
}
res.vary('Accept-Language') is not optional. Without it, any CDN or reverse proxy in front of your API will happily serve a cached French response to an English user.
Have the Angular client send X-Locale from its own build so the API agrees with what the user is actually looking at:
export const localeInterceptor: HttpInterceptorFn = (req, next) =>
next(req.clone({ setHeaders: { 'X-Locale': inject(LOCALE_ID) } }));
For server-side message catalogs, keep it boring: one JSON file per locale, with ICU message formatting.
// api/src/messages.js
import { IntlMessageFormat } from 'intl-messageformat';
import en from './locales/en-US.json' with { type: 'json' };
import fr from './locales/fr-FR.json' with { type: 'json' };
import de from './locales/de-DE.json' with { type: 'json' };
const CATALOGS = { 'en-US': en, 'fr-FR': fr, 'de-DE': de };
const cache = new Map();
export function t(locale, key, values = {}) {
const pattern = CATALOGS[locale]?.[key] ?? CATALOGS['en-US'][key] ?? key;
const cacheKey = `${locale}:${key}`;
let formatter = cache.get(cacheKey);
if (!formatter) {
formatter = new IntlMessageFormat(pattern, locale);
cache.set(cacheKey, formatter);
}
return formatter.format(values);
}
Compiling an IntlMessageFormat is expensive; caching it is the difference between a few microseconds and a visible hit on a hot path.
Now localize errors at the edge rather than scattering translations through your handlers. Return a stable machine code plus a translated message, so the client can choose to use its own copy:
import { z } from 'zod';
import { t } from './messages.js';
app.use((err, req, res, next) => {
if (err instanceof z.ZodError) {
return res.status(400).json({
code: 'validation_failed',
message: t(req.locale, 'error.validation'),
issues: err.issues.map(i => ({ path: i.path, code: i.code })),
});
}
if (err.code === 11000) {
return res.status(409).json({ code: 'duplicate_email', message: t(req.locale, 'error.duplicateEmail') });
}
console.error(err);
res.status(500).json({ code: 'internal_error', message: t(req.locale, 'error.internal') });
});
Emails follow the same path: t(user.locale, 'email.welcome.subject', { name }), with the user's stored locale, not the locale of the request that triggered the send. A nightly batch job has no request locale at all.
Step 4: translatable content in MongoDB
For content that editors own, store translations as a sub-document keyed by locale. It keeps one logical document, so publishing state, ownership and references do not fork per language.
import { Schema, model } from 'mongoose';
const localizedString = {
type: Map,
of: String,
default: undefined,
};
const productSchema = new Schema({
sku: { type: String, required: true, unique: true },
priceMinor: { type: Number, required: true },
currency: { type: String, required: true, enum: ['EUR', 'USD'] },
name: { ...localizedString, required: true }, // { 'en-US': 'Desk lamp', 'fr-FR': 'Lampe de bureau' }
description: localizedString,
defaultLocale: { type: String, default: 'en-US' },
}, { timestamps: true });
productSchema.methods.localized = function (locale, field) {
const map = this.get(field);
return map?.get(locale) ?? map?.get(this.defaultLocale) ?? null;
};
export const Product = model('Product', productSchema);
The fallback chain is the part teams forget. A half-translated catalogue must render something; returning null where a French name is missing produces blank cards in production.
Project the right language in the query rather than shipping every translation to the browser:
app.get('/api/products', async (req, res) => {
const locale = req.locale;
const products = await Product.aggregate([
{ $match: { active: true } },
{ $addFields: {
name: { $ifNull: [`$name.${locale}`, '$name.en-US'] },
description: { $ifNull: [`$description.${locale}`, '$description.en-US'] },
} },
{ $project: { sku: 1, name: 1, description: 1, priceMinor: 1, currency: 1 } },
{ $sort: { name: 1 } },
]).collation({ locale: locale.slice(0, 2), strength: 2 });
res.json(products);
});
Collation is the bit that bites
Default MongoDB sorting is byte order, so Äpfel sorts after Zebra and éclair after zucchini. That is wrong in every European locale. Pass .collation({ locale: 'de', strength: 2 }) on the query — strength: 2 is case-insensitive and accent-sensitive, which is usually what a human expects from an A–Z list.
Critically, an index is only used for a sort if its collation matches the query's. Create one index per locale you sort in:
db.products.createIndex({ 'name.de-DE': 1 }, { name: 'name_de', collation: { locale: 'de', strength: 2 } });
db.products.createIndex({ 'name.fr-FR': 1 }, { name: 'name_fr', collation: { locale: 'fr', strength: 2 } });
Then verify with explain('executionStats') that you get an IXSCAN and not an in-memory SORT stage. We have had clients whose "French site is slow" ticket was exactly this: a collation sort blowing past the 32 MB in-memory sort limit.
Full-text search per language
Stemming is language-specific. A MongoDB text index takes a single default language, so for Atlas Search define one analyzer per locale field:
{
"mappings": {
"fields": {
"name": {
"type": "document",
"fields": {
"fr-FR": { "type": "string", "analyzer": "lucene.french" },
"de-DE": { "type": "string", "analyzer": "lucene.german" },
"en-US": { "type": "string", "analyzer": "lucene.english" }
}
}
}
}
}
Then search only name.${locale}. German compound words in particular are unusable without lucene.german; a user searching Schreibtisch will not match Schreibtischlampe under the standard analyzer.
Step 5: serving the locale builds from Express 5
One Express process can serve all three bundles. Redirect the bare root using negotiation, then serve each prefix statically:
import path from 'node:path';
import express from 'express';
const DIST = path.resolve('../client/dist/client/browser');
const LOCALES = { 'en-US': 'en', 'fr-FR': 'fr', 'de-DE': 'de' };
for (const [locale, prefix] of Object.entries(LOCALES)) {
const dir = path.join(DIST, locale);
app.use(`/${prefix}`, express.static(dir, { maxAge: '1y', index: false }));
app.get(`/${prefix}/{*splat}`, (req, res) => res.sendFile(path.join(dir, 'index.html')));
}
app.get('/', (req, res) => {
const best = req.acceptsLanguages(Object.keys(LOCALES)) || 'en-US';
res.redirect(302, `/${LOCALES[best]}/`);
});
Note the Express 5 wildcard syntax, {*splat}; the bare * from Express 4 throws at route-registration time. Keep the root redirect at 302, not 301 — a permanent redirect to /fr/ gets cached in browsers and will follow an English-speaking user around forever.
Finally, emit hreflang alternates in each build's index.html so search engines index all three:
<link rel="alternate" hreflang="en" href="https://example.com/en/" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/" />
<link rel="alternate" hreflang="de" href="https://example.com/de/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/" />
If you render with Angular SSR, run one server bundle per locale behind the same prefix routing, and make sure your TransferState payload is per-locale too.
Step 6: testing and CI
Three checks keep a multi-locale app honest, and all three are cheap:
- run: npx ng extract-i18n --output-path /tmp/i18n && git diff --exit-code src/locale/messages.xlf
- run: npx ng build # i18nMissingTranslation: error fails here on any gap
- run: npx playwright test --project=fr --project=de
The first fails the build when a developer adds a translatable string without re-extracting. The second fails on missing translations. The third runs your end-to-end suite against the French and German bundles:
test('French locale shows localized heading and currency', async ({ page }) => {
await page.goto('/fr/products');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Vos produits');
await expect(page.getByTestId('price').first()).toHaveText(/19,99\s€/);
});
Add a pseudo-locale (fr-XA, where every string is accented and padded ~35% longer) to catch layout breakage before a translator ever delivers copy. German strings routinely run 30% longer than English, and the button that fits "Save" rarely fits "Speichern unter".
The short checklist
- Locale in the URL path;
Vary: Accept-Languageon anything negotiated. - Explicit message IDs and ICU plurals in every translatable string.
i18nMissingTranslation: "error"so gaps fail the build, not production.- UTC instants in MongoDB; format at the edge with
Intl. - Money as minor units plus a currency code.
- A per-locale collation index for every sorted list, verified with
explain. - A per-language analyzer for search.
- Pseudo-locale and per-locale E2E runs in CI.
Retrofitting this onto a live application is mostly archaeology: finding hard-coded strings, formatted dates stored as strings, and sorts that were never going to work outside English. If that is where you are, our MEAN Stack Consulting and Performance Tuning teams do this work regularly — get in touch with your locale list and your current stack versions and we will scope it.