DEV Community

Cover image for Localizing a Django app into 7 languages — RTL, a JS game, and the honest parts
Ahsan Ahmad for VanillaPM

Posted on AI-assisted

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/<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
    # ...
]
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

5. RTL, for free-ish — emit the direction and use logical CSS:

<html lang="{{ LANGUAGE_CODE }}" dir="{% if LANGUAGE_BIDI %}rtl{% else %}ltr{% endif %}">
Enter fullscreen mode Exit fullscreen mode
/* not margin-left / left / text-align:left */
.card { margin-inline-start: 1rem; text-align: start; }
Enter fullscreen mode Exit fullscreen mode

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:

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

  1. Split chrome from content. gettext for the frame; per-language files for prose.
  2. Keep a do-not-translate vocabulary. Domain terms that must stay in one language.
  3. RTL = logical CSS properties, decided once, not a second stylesheet.
  4. Fallback per file, so a half-done language degrades to English instead of 404ing.
  5. A parity test so structure can't silently drift between languages.
  6. 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.

Top comments (1)

Collapse
 
sgaggjhkjh profile image
Arjun Sharma •

The chrome vs content split is the insight I was missing. And I appreciate the honesty about labeling AI-assisted drafts as beta: shipping machine translation labeled as provisional is much better than quietly passing it off as final.