The language goes in the URL, not the browser
On public pages, make the language part of the address: /ar/... and /en/.... The URL is the single source of truth. Search engines see two separate pages, a shared link opens in its own language, and the language toggle takes you to the same page in the other language without jumping to the top.
An unknown prefix redirects to the default language, and so does the bare home page. Keep localStorage for the admin and sign-in pages that search engines never need. And link the two versions with hreflang, plus an x-default.
export const langResolver: ResolveFn<AppLang> = async (route, state) => {
const translations = inject(TranslationCatalogService);
const router = inject(Router);
const lang = parseLang(route.paramMap.get('lang'));
if (!lang) {
// Unknown locale: rewrite the first segment to the default one.
const rest = state.url.split('/').filter((s) => s.length > 0);
if (rest.length > 0) rest[0] = DEFAULT_LANG;
else rest.push(DEFAULT_LANG);
return new RedirectCommand(router.parseUrl(`/${rest.join('/')}`), { replaceUrl: true });
}
await translations.switchTo(lang);
return lang;
}; A route resolver: a known language, or a redirect Flip the direction after the words arrive
Flip dir before the translation file has loaded and the user sees a mirrored layout in the old language for a moment. The right order: load the words, switch the language, then flip the direction. And when someone taps the toggle twice in a row, the newest request wins and the older one is dropped quietly.
Set dir and lang on the html element through Renderer2 and the injected document, not the global document, so it works on the server too. Angular Material reads direction from the CDK’s Directionality, so replace it with a service that follows the app’s language, or menus and dialogs keep opening from the wrong side.
async switchTo(lang: AppLang): Promise<boolean> {
this.latest = lang;
await this.ensure(lang); // lazy-load the dictionary chunk
if (this.latest !== lang) return false; // a newer toggle won
await firstValueFrom(this.translate.use(lang));
this.localization.setLanguage(lang);
return true;
}
// App root: one effect sets <html dir lang>, in the browser and on the server.
effect(() => {
const lang = this.localization.language();
const root = this.document.documentElement;
this.renderer.setAttribute(root, 'dir', lang === 'ar' ? 'rtl' : 'ltr');
this.renderer.setAttribute(root, 'lang', lang);
}); The words first, the direction last Logical CSS properties first
margin-inline-start instead of margin-left, padding-inline instead of padding-right, inset-inline-end instead of right. Then the layout flips with dir on its own, with no second stylesheet. Amlaak has about 153 logical properties to about 12 physical ones.
Keep [dir=rtl] for what has no logical version: transform, transform-origin and animation. And one rule for icons: flip only the ones that point somewhere, like arrows and next and previous. A check mark, a play button and a search icon stay as they are.
[dir='rtl'] .flip-rtl {
transform: scaleX(-1);
}
.card-cta {
margin-inline-start: auto; /* right in English, left in Arabic */
padding-inline: 1rem 0.75rem;
} One class for icons that point a direction An Arabic font that does not weigh down the English page
Split the font with unicode-range into an Arabic subset and a Latin one, and the English page never downloads Arabic letters. Preload only the subset the first screen needs: Alreef preloads Tajawal because Arabic text is the biggest thing on its first paint, and Amlaak’s server sends different preload hints per language.
A small one: write the other language’s name on the toggle in the system font, or that one button pulls the other font file into every page. And letter-spacing pulls joined Arabic letters apart, so turn it off on Arabic pages.
Numbers and dates
ar-SA on its own gives you the Hijri calendar and Arabic-Indic digits in many browsers. If the business runs on the Gregorian calendar with Latin digits, ask for that explicitly, and set the time zone instead of trusting the user’s device.
const date = new Intl.DateTimeFormat('ar-SA-u-ca-gregory-nu-latn', {
dateStyle: 'medium',
timeZone: 'Asia/Riyadh',
}).format(new Date());
const compact = new Intl.NumberFormat('ar-u-nu-latn', { notation: 'compact' }).format(1250000); Gregorian, Latin digits, Riyadh time Mixed text
A sentence that mixes Arabic, numbers and English follows the bidirectional algorithm, and the result can surprise you. Angular Material’s paginator builds its range label by gluing numbers to words, and in an Arabic interface it comes out scrambled. Alreef’s fix: translate the whole phrase as one template with placeholders instead of gluing numbers beside translated words.
When users write the text themselves, as in chat, do not force the interface’s direction on it. In Project Pavilion each message takes its direction from the script most of its letters use, and the dir attribute survives HTML sanitizing so the direction is not lost on the way.
export function detectContentDirection(text: string | null | undefined): 'rtl' | 'ltr' | null {
if (!text || !text.trim()) return null;
const arabic = text.match(ARABIC_G)?.length ?? 0;
if (arabic === 0) return 'ltr';
const latin = text.match(LATIN_G)?.length ?? 0;
return arabic >= latin ? 'rtl' : 'ltr';
} A message’s direction from its own text With server-side rendering
The server knows the language from the URL, so it should write the right dir and lang into the HTML from the start. Few things look worse than an Arabic page that draws from the left and then jumps.
When pages are prerendered in one language, as Alreef’s are in Arabic, a visitor who chose English sees Arabic for a moment before everything switches. The fix there: a small inline script in index.html reads the saved choice and hides the page until it has rendered in English, with a three-second fallback, and the script’s hash pinned in the CSP. It is a workaround with a cost, a later first paint for those visitors; server rendering per language avoids it.
Before you ship
- Does every public page have a URL in its own language, with hreflang between the two?
- Does the direction flip after the words arrive, and does the last tap on the toggle win?
- Do Material menus and dialogs open from the right side?
- Is there any margin-left or right left without a reason?
- Do only the arrows flip, not the check mark or the play button?
- Does the English page download the Arabic font? Open the network tab and check.
- Are dates Gregorian or Hijri on purpose, and in which digits?
- Do both languages have the same translation keys, and does the build fail when they do not?