Most teams treat right-to-left writing systems as a "phase 2" retrofit. They almost always regret it. Here's why we built Arabic and Persian into Modir from the first commit, and the architectural implications that ripple through the stack.
Every wealth platform we have ever joined claimed to support Arabic. Most of them shipped a translation file, called the project done, and were promptly broken at the second screen by a misaligned input or a left-anchored modal. The pattern is so consistent that we have a name for it inside Modir: the "translation table" trap. The team thinks localization is a strings problem; the regulator and the user discover, six months later, that it is a layout problem, a calendar problem, a numerals problem, a bidirectional-text problem, and an input-validation problem all at once.
This post is the architectural argument we wish someone had handed us at the beginning. We will walk through why RTL is structural, what fails when it is treated as cosmetic, and how Modir's architecture absorbs it as a first-class concern — including the lint rules that make backsliding mechanically impossible.
The "translation table" trap
The mental model goes like this: "Strings are the variable bit. Everything else is the same. So we'll lift the strings into a JSON file, render them based on a locale, and call it done." This works for languages that share the writing direction, glyph metrics, and number rendering of English. It does not work for languages that don't.
Arabic flows right-to-left. So does Persian. Numbers in those languages have their own glyphs (٠–٩ for Arabic, ۰–۹ for Persian) and their own thousand and decimal separators. Mixed-direction text — an English ticker symbol embedded in an Arabic sentence about a portfolio — needs the Unicode Bidirectional Algorithm to resolve correctly, and that algorithm does not run on its own; you have to mark the boundaries with <bdi> tags or it produces visually correct but logically wrong output.
Worse, English-language layouts are not direction-agnostic. They embed direction implicitly. margin-left: 24px means something different in RTL than in LTR — but the CSS engine doesn't know your intent. The browser renders exactly what you wrote, which means a label is now visually overlapping its input field and the explanation appears to the right of the icon instead of to the left of it.
Logical properties: the structural fix
The architectural answer is to stop talking about "left" and "right" and start talking about "start" and "end." Modern CSS has logical properties exactly for this. Instead of margin-left, write margin-inline-start. Instead of border-right, write border-inline-end. Instead of padding-left, write padding-inline-start.
The rule we use is mechanical: physical properties (left, right, top, bottom in margins, paddings, borders, and positioning) are banned. ESLint enforces it. CI fails on a ml-6 Tailwind class as confidently as it fails on a syntax error.
// .eslintrc — abridged
{
"rules": {
"no-restricted-syntax": ["error",
{
"selector": "Property[key.name=/^(margin|padding|border|left|right)/]",
"message": "Use logical properties: margin-inline-start instead of margin-left."
}
]
}
}The Tailwind side is the same. Our Tailwind config bans ml-*, mr-*, pl-*, pr-*, border-l-*, border-r-*, and the left-* / right-* positioning utilities. We use ms-*, me-*, ps-*, pe-*, border-s-*, border-e-*, start-*, end-* exclusively. The Tailwind RTL plugin generates the right output for both directions from the same source.
Direction is data, not detection
The next mistake is to compute direction in the browser via navigator.language or document.dir. That is too late. By the time the browser knows the direction, the page has already rendered, the user has already seen the wrong layout, and the page is already laying out left-anchored content on a right-anchored body.
We set <html dir> server-side, in middleware, on every request. The locale resolution chain — URL → header → cookie → Accept-Language → tenant default — runs before the response is built. By the time the HTML is streamed, it knows its direction.
Bidirectional text is its own problem
An Arabic sentence that contains an English word — a ticker symbol, a brand name, an account number — has competing direction signals. The Unicode Bidirectional Algorithm resolves them, but it needs help. The convention is to wrap embedded foreign-direction substrings in <bdi> elements. Account numbers, IBANs, ISINs, and ticker symbols always render LTR even when they appear inside an RTL paragraph. A <bdi> tag scopes the algorithm.
Free-form text fields — anywhere a user might type in either direction — should set dir="auto" so the browser chooses based on the first strong directional character. This is the right default for chat, comments, names, and email addresses. It is the wrong default for fixed-format identifiers, which should always be LTR regardless of context.
The pseudo-locale pattern
The most useful trick we picked up shipping Modir is the pseudo-locale. We auto-generate a locale called en-XA from en-US by replacing every character with a diacritic-bearing equivalent and inflating every string by 30%. "Hello world!!" becomes "[Ĥéĺĺó wóŕĺd!! ~~]".
The pseudo-locale is LTR, but it breaks layouts the same way Arabic does — strings are longer, glyphs are wider, line-heights need slack. If a UI fails on pseudo, it will fail on Arabic. Since the build generates pseudo automatically from English on every PR, regressions show up immediately, in CI. We catch the same problems we used to catch on the night before launch.
Calendars are not text
Even if you've fixed the layout, you haven't fixed the calendar. Hijri (the Saudi Arabia default) is not a translation of Gregorian; it is a different calendar with different month lengths, different leap-year rules, and a different epoch. Jalali (the Iran default) is yet another. None of them maps cleanly to a Gregorian formatter — you cannot solve "render this date in Hijri" by running Gregorian through a string substitution table.
Modir keeps every timestamp in Postgres as timestamptz UTC. Calendar conversion happens at the rendering boundary only — in the UI, in PDF generation, in email. @umalqura/core handles Hijri Umm al-Qura; jalaali-js handles Jalali. Eight reference dates (1 Muharram 1446 AH = 7 July 2024, 1 Farvardin 1403 = 20 March 2024, and so on) are verified by pnpm calendar:check on every PR.
The architectural rule is that no server-side calculation ever speaks anything other than UTC. If a workflow signals "wait until Friday in Riyadh," the Friday is computed against an IANA timezone (Asia/Riyadh) on top of UTC; if a Hijri date appears in a workflow input, the workflow stores the underlying Gregorian and re-renders Hijri at output time. This is the only way the audit chain stays consistent across locales: every event is timestamped in one universal frame, and the frame never moves.
Numerals are not strings
Three numbering systems ship in the platform: Latin (0–9), Arab (٠–٩), and Arab-extended (۰–۹). Users select theirs in their preferences. The trap is that JavaScript's Number only speaks Latin: parseInt('١٢٣') in standard JavaScript returns NaN. So a NumberInput that accepts Arab-numeral input has to round-trip to Latin internally and back to Arab on render.
The same applies to currency formatters. Intl.NumberFormat handles the digit substitution well — but only if you pass it the right options object — and a CurrencyInput that does not normalize on submit will silently drop the digits when the API rejects them. The fix is a thin wrapper: every numeric input round-trips to Latin internally; every numeric output renders in the user's preferred system; and every server-side calculation operates on Latin only.
Catalog parity is enforced
The string-table problem still exists; we just don't trust ourselves to manage it manually. pnpm i18n:check runs in CI and fails if catalogs drift: a key present in en-US but missing in ar-SA is a hard failure. ICU MessageFormat validity is checked by parsing every string. The pseudo-locale catches plural-rule breakage that flat string lists would not — Arabic has six plural categories (zero, one, two, few, many, other) where English has two; pseudo demonstrates the gap visually.
Architectural recap
Treating RTL as structural changes the dependency graph in seven specific places:
- The CSS layer enforces logical properties through ESLint and Tailwind config — physical properties cannot land.
- The HTML layer sets
<html dir>server-side from middleware — never client-detected. - The component library wraps fixed-format identifiers in
<bdi>and free-form input indir="auto"— by default, not by request. - The build generates a pseudo-locale on every PR — regressions surface in days, not in launches.
- The data layer stores all timestamps in UTC — calendars are output-only.
- The number-input layer round-trips through Latin internally — server-side code only sees one numbering system.
- The CI layer fails on catalog drift and ICU invalidity — the team cannot ship a partially-translated locale by accident.
None of these is hard individually. What is hard is doing all seven on a codebase that started as English-only. We did the work upfront because we had no choice — Arabic and Persian launched before English in our first deployment. We would do it the same way again on greenfield work. Retrofits never turn out as cleanly.
Recommended reading
- The W3C's Internationalization Quick Tips for the Web
- The Unicode Bidirectional Algorithm (UAX 9)
- The MDN page on logical properties (
margin-inline-startet al.) - The ICU MessageFormat specification
- Modir's open-source
@wealthos/calendarspackage — Hijri, Jalali, Gregorian implementations with reference-date tests
Engineering posts represent the views of the authors. Mention of regulators or jurisdictions does not constitute legal advice.