Dev.to · 6 min read

Localizing a Django app into 7 languages — RTL, a JS game, and the honest parts

Localizing a Django app into 7 languages — RTL, a JS game, and the honest parts

I localized a large Django app into seven languages — English plus the six UN official languages (Arabic, Chinese, Spanish, French, Russian) and Bengali — on my own, as one developer. That covers something close to half the planet by first or second language. This is the honest write-up: why it was worth doing, how the pieces fit, the technical challenges that actually cost me time, and a short tutorial so you can add a language to your own Django app this afternoon. You can see the result live — the Learning Centre and its PMP course render in all seven. Why bother (for a solo, pre-revenue project) The product is free and the thing most worth translating is the learning content — a free PMP course and a spaced-repetition trainer. A project manager in Dhaka or Cairo shouldn't have to read it in their third language. There's a selfish reason too: localization multiplied the indexable surface. One English tutorial became seven; the sitemap went from a few dozen URLs to a few hundred. For a young site that's a lot more doors for search engines to find — indexing is a slow game, but you can't rank a page that doesn't exist. And one honest constraint up front: the six non-English locales are AI-assisted drafts under review, and the app says so with a small "translation in beta" label. Shipping machine-first translation is fine; pretending it's human-reviewed isn't. The one decision that shaped everything: chrome vs content The trap is treating "localize the app" as one problem. It's two, with different tools: Chrome — the UI frame: nav, buttons, labels, form text. This is classic Django gettext (.po/.mo). Content — long-form prose: the tutorial lessons. Translating that through gettext would be miserable (a msgid per paragraph). Instead, each lesson is a Markdown file per language, with English as the fallback. Keeping those two systems separate is what made the whole thing tractable. A third rule cuts across both: core domain terms stay English. "Work Breakdown Structure", "Earned Value", the PMBOK process names — translating those would break exam alignment and confuse practitioners who learned them in English. So there's a small do-not-translate vocabulary the translators (and the tests) respect. The challenges that actually cost me time 1. RTL is a layout problem, not a text problem. Flipping Arabic text is free — the browser does it with dir="rtl". What breaks is every hardcoded margin-left, padding-right, text-align: left, left: 0. The fix is CSS logical properties: margin-inline-start instead of margin-left, inset-inline-start instead of left, text-align: start. They're identical in LTR and auto-flip in RTL — so English renders byte-identically and Arabic mirrors correctly, with no duplicate stylesheet. 2. zh-hans vs zh. Django's language tag is zh-hans and the URL prefix uses it (/zh-hans/…). But I named the content directories by the base tag (zh/), via get_language().split('-')[0]. Mixing these up gives you a page that 404s its own translation. Pick one convention for URLs (full tag) and one for content lookup (base tag), and write it down. 3. The JavaScript game. Server-side {% trans %} can't reach strings rendered by JS. Django's answer is the djangojs catalog: makemessages -d djangojs, serve JavaScriptCatalog, and call gettext() in your scripts. The PMP Reps game runs its whole UI through that — while the card data (ITTO terms) stays English by design. 4. A half-translated language must never break a page. Translators finish at different rates. So the content loader resolves documents/learn/pmp//.md if it exists, else the English file — per file, per language. A missing Bengali lesson quietly serves English instead of a 404. The structure (section anchors, IDs) comes from the English file always, so a deep link like /bn/learn/pmp/x/#s-itto resolves identically in every language. 5. Enforcing that structure. "The English file is the structure; a translation is the same structure in another language" is only true if something checks it. A parity test asserts every translated file carries the same section anchors and that the do-not-translate blocks are byte-identical — so a translator can change heading text without silently breaking a cross-link. 6. Picking the language for the visitor. Anonymous visitors get a best guess from Cloudflare's CF-IPCountry header mapped to a language; signed-in users get the country they registered with, frozen. An explicit language switch always wins and is remembered. (See the switcher in the nav.) A 20-minute tutorial: add a language to Django The infrastructure is genuinely small. Here's the whole shape. 1. Declare the languages + turn on i18n (settings.py): USE_I18N = True LANGUAGE_CODE = "en" LANGUAGES = [ ("en", "English"), ("bn", "বাংলা"), ("ar", "العربية"), ("es", "Español"), ] LOCALE_PATHS = [BASE_DIR / "locale"] MIDDLEWARE = [ # ... "django.middleware.locale.LocaleMiddleware", # after Session, before Common # ... ] 2. Prefix your URLs by language (urls.py): from django.conf.urls.i18n import i18n_patterns urlpatterns = [path("i18n/", include("django.conf.urls.i18n"))] # the set-language view urlpatterns += i18n_patterns( path("learn/", learning_centre, name="learn-centre"), # English stays at /learn/, others become /bn/learn/, /ar/learn/, … prefix_default_language=False, ) 3. Mark strings in templates: {% load i18n %} {% trans "Learning Centre" %} {% blocktrans %}Learn by doing, on a real project.{% endblocktrans %} 4. Extract, translate, compile: python manage.py makemessages -l bn -l ar -l es # writes locale//LC_MESSAGES/django.po python manage.py makemessages -d djangojs -l bn # the JS catalog, if you have one # ...fill in the msgstr values... python manage.py compilemessages # .po → .mo 5. RTL, for free-ish — emit the direction and use logical CSS: /* not margin-left / left / text-align:left */ .card { margin-inline-start: 1rem; text-align: start; } That's a working multilingual Django app. Long-form content is the only part that lives outside gettext — resolve a per-language file with an English fallback, and you're done. See it live (all seven) The same PMP course, one running project, seven languages: English বাংলা / Bengali العربية / Arabic (RTL) 中文 / Chinese Español / Spanish Français / French Русский / Russian The Reps game Learning Centre New tracks are opening the same way — Learn Finance and Learn HR — and the roadmap and what's shipped are public too. What I'd tell myself at line 1 Split chrome from content. gettext for the frame; per-language files for prose. Keep a do-not-translate vocabulary. Domain terms that must stay in one language. RTL = logical CSS properties, decided once, not a second stylesheet. Fallback per file, so a half-done language degrades to English instead of 404ing. A parity test so structure can't silently drift between languages. Be honest about machine translation — label it until a human has reviewed it. Seven languages from one developer wasn't the heroic part. The discipline of keeping two translation systems, one do-not-translate list, and an English fallback was — and that's what made it hold together. I build VanillaPM — a free, full-lifecycle PM platform — in the open, and write up what breaks along the way. The rest of the series is here.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News