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
gettextwould be miserable (amsgidper 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/<lang>/<slug>.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 %}
<a href="{% url 'learn-centre' %}">{% trans "Learning Centre" %}</a>
<p>{% blocktrans %}Learn by doing, on a real project.{% endblocktrans %}</p>
4. Extract, translate, compile:
python manage.py makemessages -l bn -l ar -l es # writes locale/<lang>/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:
<html lang="{{ LANGUAGE_CODE }}" dir="{% if LANGUAGE_BIDI %}rtl{% else %}ltr{% endif %}">
/* 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.
gettextfor 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.











