Back to blog

Right to Left Language Support in Django Apps

2026-06-04 12 min read
Right to Left Language Support in Django Apps

Meta description: Fix broken right to left language support in Django with proper dir handling, BiDi-safe templates, logical CSS, and RTL testing.

You add Arabic, compile messages, deploy, and your app looks like it lost a fight with Flexbox. The navbar is still anchored for LTR. The back arrow points the wrong way. Breadcrumbs read backward. A form label sits on the right, but the validation icon is still shoved to the left like nothing changed.

That's the usual failure mode with a right to left language rollout in Django. The translations are there. The UI still breaks because RTL isn't a string problem. It's a rendering, layout, and QA problem that leaks into templates, CSS, and .po files.

I just spent a sprint cleaning up one of these. The fixes weren't exotic. Most of the damage came from hardcoded left, right, and a team assumption that LocaleMiddleware would somehow make the rest of the interface behave.

When Your UI Breaks in a Right to Left Language

You usually notice the breakage in the first minute.

A sidebar that should move to the right stays on the left. Buttons keep their LTR spacing. Search icons overlap Arabic placeholder text. Tables feel off even when the strings are translated correctly. Then someone pastes an English filename into an Arabic sentence and punctuation jumps to the wrong side.

A stressed developer looking at a computer screen with a dashboard including right to left text.

Why this isn't a niche feature

Arabic, Hebrew, and Persian are the languages most Django teams think about first, but the scope is bigger than that. The W3C Internationalization group says RTL scripts cover 12 scripts and 215 languages, with 2,305,048,719 potential users, while also noting that estimate is likely an overcount and that actual users may still exceed one billion, which is enough to treat RTL as infrastructure, not a corner case (W3C Internationalization data).

That changes how you prioritize it. You're not adding cosmetic support for one market. You're fixing a platform assumption your frontend has probably baked in for years.

What usually goes wrong

The bad implementations tend to fail in the same places:

If you want a broader non-Django checklist for avoiding software localization issues, that resource is worth skimming. The useful part is the reminder that translated copy alone doesn't equal a localized product.

Broken RTL support makes your app feel unfinished even when every msgstr is filled in.

How Browsers Render RTL Content with Unicode BiDi

Browsers don't guess. They follow rules.

The two pieces that matter are the document direction and the Unicode Bidirectional Algorithm. If you only remember one thing from this section, remember this: dir="rtl" is the switch that tells the browser how to treat the page, and BiDi rules decide how mixed RTL and LTR characters appear inside that page.

dir is the real trigger

For web UIs, RTL support isn't just swapping text alignment. The browser and layout engine also need to mirror direction-sensitive components through the HTML dir attribute or CSS direction: rtl, so navigation order, alignment, and flow render correctly across mixed-script content. Curity notes that when dir is set, the browser interprets it and flips or renders the content accordingly (Curity RTL guidance).

That's why this works better:

<html dir="rtl" lang="ar">

than trying to fake RTL with only CSS like:

body {
  text-align: right;
}

The second version only changes appearance. The first one changes how the browser lays out the page.

Why mixed text still breaks

Even with dir="rtl", your strings can still render badly when they mix Arabic or Hebrew with LTR content like:

The Unicode BiDi rules handle strong and weak directional characters differently. Letters usually carry stronger direction. Punctuation and numbers can inherit direction from nearby text. That's why a sentence can look correct in one template and wrong in another after you add a colon, slash, or parentheses.

If your bug report says “Arabic text looks random around codes,” the problem usually isn't the translation. It's BiDi behavior in a mixed string.

What the browser will and won't do for you

Here's the practical split:

Browser handles for you You still need to handle
Base text flow after dir is set Component-specific mirroring
Inline text ordering rules CSS written with left and right
Some alignment defaults Icons, assets, and charts
Mixed-script rendering rules Real-world QA with translated strings

If you skip that second column, you get the classic half-working interface. Text flows right to left, but the app still feels built for English.

Implementing RTL Direction in Your Django Templates

Django already gives you the flag you need. However, it is often not used at the document root.

If LocaleMiddleware is enabled, the template context includes LANGUAGE_BIDI. Use it on the <html> tag. Don't invent your own per-language checks in templates.

The base template fix

Your settings.py needs the usual i18n pieces in place:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

USE_I18N = True

Then in base.html:

{% load i18n %}
<!doctype html>
<html lang="{{ LANGUAGE_CODE }}" dir="{{ LANGUAGE_BIDI|yesno:'rtl,ltr' }}">
  <head>
    <meta charset="utf-8">
    <title>{% block title %}{% translate "Dashboard" %}{% endblock %}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>
  <body>
    {% block content %}{% endblock %}
  </body>
</html>

That line does most of the heavy lifting:

dir="{{ LANGUAGE_BIDI|yesno:'rtl,ltr' }}"

Don't hardcode direction lower in the tree

I've seen teams do this in individual components:

<div class="sidebar" dir="rtl">

That usually creates inconsistent rendering when other parts of the page stay LTR. Set direction at the document level unless you have a very specific mixed-direction component.

A sane rule is:

Django's docs cover the i18n stack and template tags well if you need a refresher on the framework side (Django internationalization documentation).

One more thing that catches teams

Language detection and direction are related, but they're not the same problem. If your app picks locales from the browser, make sure that logic is stable before you debug the layout. I've seen teams waste hours “fixing RTL” when the page was switching languages inconsistently. If you want to tighten that up, this post on browser language detection in Django is useful.

Practical rule: Put dir on <html>, not on random containers. If the root direction is wrong, every downstream fix becomes a hack.

Writing Modern CSS for RTL and LTR Layouts

Most botched RTL work comes from old CSS, not Django.

If your stylesheet is full of margin-left, padding-right, left: 0, and text-align: left, you've hardcoded LTR assumptions into every component. You can patch that with [dir="rtl"] overrides, but the better long-term move is to write direction-agnostic CSS.

A comparison chart showing the benefits of modern CSS logical properties versus traditional physical CSS for international layouts.

Replace physical properties with logical ones

Start here:

Avoid Use instead
margin-left margin-inline-start
margin-right margin-inline-end
padding-left padding-inline-start
padding-right padding-inline-end
left / right for layout inset-inline-start / inset-inline-end
text-align: left text-align: start

Example:

.page-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-inline: 1rem;
  border-block-end: 1px solid var(--border);
}

.button-icon {
  margin-inline-end: 0.5rem;
}

.form-help {
  text-align: start;
}

That gets you much farther than a giant RTL override file.

Use targeted RTL overrides where the layout is directional

Logical CSS won't solve everything. Some components have explicit direction and need a real override.

.breadcrumbs {
  display: flex;
  gap: 0.5rem;
}

[dir="rtl"] .breadcrumbs {
  flex-direction: row-reverse;
}

.sidebar {
  border-inline-end: 1px solid var(--border);
}

[dir="rtl"] .sidebar {
  border-inline-end: 0;
  border-inline-start: 1px solid var(--border);
}

.back-link svg {
  transform: scaleX(1);
}

[dir="rtl"] .back-link svg {
  transform: scaleX(-1);
}

Here's the trap. Don't mirror everything.

Practical RTL implementation requires selective mirroring, not a full horizontal flip. Directional elements like back buttons, menus, and breadcrumbs should reverse, while symmetrical icons and media controls that represent time or progress usually should not be mirrored (RTL design guidance from Muzli).

That's where teams get sloppy. They flip arrow icons correctly, then also flip play buttons, progress controls, maps, or illustrations that now say the wrong thing.

A short demo helps if your team needs to see the visual effect in motion.

What works in practice

A good refactor order looks like this:

If you maintain a design system, fix it there. If you patch app screens one by one, you'll keep reintroducing LTR assumptions.

Handling RTL in Django .po Files and Translations

RTL bugs often survive a clean visual QA pass, then blow up in production the first time a real Arabic sentence includes a filename, order code, or placeholder. I see it in Django projects when the templates are technically correct, but the msgstr values in locale/ar/LC_MESSAGES/django.po were edited without respecting BiDi behavior or Python interpolation.

A realistic .po example

Django will generate entries like this under locale/ar/LC_MESSAGES/django.po:

#: billing/templates/billing/invoice.html:18
#, python-format
msgid "Invoice %(number)s for %(name)s is overdue."
msgstr "الفاتورة %(number)s الخاصة بـ %(name)s متأخرة."

#: uploads/forms.py:42
#, python-format
msgid "File %(filename)s is not allowed."
msgstr "الملف %(filename)s غير مسموح به."

#: accounts/templates/accounts/reset_email.html:11
msgid "Reset your password"
msgstr "أعد تعيين كلمة المرور"

Two things break here.

First, placeholders are part of the contract. If someone changes or drops %(filename)s, Django raises an error at runtime. Second, mixed-direction text can reorder punctuation and neutral characters in ways that look fine in the .po file but render badly in the browser once a real value lands. Filenames, coupon codes, email addresses, and invoice numbers are the usual offenders.

The fix starts with process, not guesswork.

Keep your Django translation workflow boring

Use Django's gettext flow and keep the files in Git:

python manage.py makemessages --locale=ar
python manage.py compilemessages

Review .po diffs the same way you review Python code. Check placeholders, punctuation, whitespace, and any embedded HTML. In RTL work, those are behavior, not copy polish.

If your team needs a quick refresher on file structure, plural forms, and flags, this guide to gettext .po files in Django covers the parts that usually get missed during implementation.

What to watch for in RTL translations

A plain translation review is not enough. Test the rendered string with real data.

For example, this can produce confusing output once %(filename)s becomes an English filename with dots, dashes, or parentheses:

msgid "File %(filename)s is not allowed."
msgstr "الملف %(filename)s غير مسموح به."

In practice, I test strings like this with actual values in a Django shell or fixture data, then load the page:

from django.utils.translation import gettext as _
print(_("File %(filename)s is not allowed.") % {"filename": "report-final-v2.pdf"})

That catches problems earlier than staring at msgstr lines in a code review.

Where automation helps and where it still needs review

Manual spreadsheet translation is where Django teams usually damage placeholders and HTML fragments. TranslateBot is one example of a Django-oriented workflow that runs through manage.py translate and writes translated msgstr values back to locale files while preserving placeholders and markup.

That still does not remove the need for review. Short labels lack context, support copy often uses mixed-direction tokens, and translators can choose wording that changes the reading order around punctuation.

The same trade-off shows up in adjacent systems like AI-powered customer service translations. Automation speeds up throughput. Human review catches formatting, intent, and context failures before users do.

Treat .po changes like code. Verify placeholders, compile the messages, and render the string with real RTL content before merging.

A Practical Checklist for Testing RTL Support

You don't need to be a native Arabic speaker to catch most RTL bugs. You do need a repeatable test pass before deploy.

A checklist infographic outlining ten essential steps for testing Right-To-Left language support in web design.

What to verify before shipping

Run through this with real translated strings, not placeholders:

Put it into your process

The teams that keep RTL healthy don't treat it as a one-time cleanup. They add RTL screenshots to PR review, run locale-specific smoke tests, and keep a known set of mixed-direction fixtures in templates and factories.

If you're building out the QA side, this post on localization in testing is a good companion read because it focuses on how to make this repeatable instead of heroic.

The fastest RTL audit is still a real page with real translations and a developer clicking every awkward component by hand.


If your Django project already uses makemessages and compilemessages, the next useful step is to stop treating translation as an external copy-paste task. TranslateBot fits into the existing manage.py workflow, writes changes back to your locale files, and keeps placeholders intact so you can review RTL translation diffs in Git instead of cleaning up broken strings after deploy.

Stop editing .po files manually

TranslateBot automates Django translations with AI. One command, all your languages, pennies per translation.