All insights Frontend Development

Building Multilingual RTL Websites with Nuxt

How we build English, Persian and Arabic sites from one Nuxt codebase: i18n routing, hreflang, dir, logical CSS, fonts, Jalali dates and the SEO traps to avoid.

Asan Team6 min read

The site you are reading runs in English, Persian and Arabic from one Nuxt codebase. Two of those languages read right to left, use different digits, and one of them usually shows dates in a different calendar. Getting that right is less about translation and more about a dozen small engineering decisions, most of which are invisible when they are correct and painful when they are not.

This is how we build multilingual, right-to-left sites with Nuxt, including the SEO details that decide whether Google treats your language versions as one site or as competing duplicates.

Routing: one URL per language

Every page needs a distinct, crawlable URL in every language. The cleanest scheme for most sites is a locale prefix, with the default language at the root:

  • https://asan.team/services in English
  • https://asan.team/fa/services in Persian
  • https://asan.team/ar/services in Arabic

With @nuxtjs/i18n that is the prefix_except_default strategy:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    locales: [
      { code: 'en', language: 'en-US', file: 'en.json', dir: 'ltr' },
      { code: 'fa', language: 'fa-IR', file: 'fa.json', dir: 'rtl' },
      { code: 'ar', language: 'ar-SA', file: 'ar.json', dir: 'rtl' },
    ],
    defaultLocale: 'en',
    strategy: 'prefix_except_default',
    baseUrl: 'https://asan.team',
    langDir: 'locales/',
  },
})

Two settings matter more than they look. baseUrl must be the absolute origin, because canonical and hreflang URLs are useless as relative paths. Declaring dir on each locale means direction comes from configuration rather than from an if (locale === 'fa') check somewhere in a component. That check is exactly how Arabic ends up rendering left to right when someone adds it later.

Direction and language on the html element

The lang and dir attributes belong on <html>, set on the server so the first paint is already right:

// app.vue
const { locale, localeProperties } = useI18n()
const direction = computed(() => localeProperties.value.dir ?? 'ltr')
const i18nHead = useLocaleHead({ addSeoAttributes: true })

useHead({
  htmlAttrs: { lang: locale, dir: direction },
  link: computed(() => i18nHead.value.link ?? []),
  meta: computed(() => i18nHead.value.meta ?? []),
})

useLocaleHead also generates the canonical link, the hreflang alternates and the og:locale tags for every page. That covers most of the SEO wiring in one call.

hreflang: telling Google the pages are translations

Without hreflang, Google sees /services, /fa/services and /ar/services as three separate pages with overlapping topics, and may pick one to show everywhere. With hreflang, each page declares its siblings:

<link rel="alternate" hreflang="en" href="https://asan.team/services">
<link rel="alternate" hreflang="fa" href="https://asan.team/fa/services">
<link rel="alternate" hreflang="ar" href="https://asan.team/ar/services">
<link rel="alternate" hreflang="x-default" href="https://asan.team/services">

The rules that trip teams up:

  • Alternates must be reciprocal. Every page in the set lists all the others, including itself.
  • Every alternate must return 200. An hreflang pointing at a 404 or a redirect is ignored, and repeated errors make Google trust the rest less.
  • Add x-default for visitors whose language you do not serve.
  • Repeat the set in the sitemap. We generate the sitemap from the prerendered output, so every URL in it exists and carries its alternates.

Content that exists in only one language needs special handling. Our insights are written separately for each language, not translated, so a Persian article has no English twin. Its page drops the hreflang alternates entirely and keeps only its canonical. The language switcher sends visitors to the insights index in their language rather than to a URL that would 404.

Do not redirect crawlers by browser language

A common mistake is redirecting every visitor to the language their browser prefers. Googlebot usually crawls without an Accept-Language header, from US IP addresses. Aggressive redirects can hide your non-English pages from it entirely, and they frustrate users who deliberately chose a language.

Our rule: detect the language only on the root URL, remember the choice in a cookie, and never redirect a URL that already carries a locale prefix. In @nuxtjs/i18n that is detectBrowserLanguage with redirectOn: 'root' and alwaysRedirect: false.

CSS: think in start and end, not left and right

Right-to-left layout is mostly solved by one habit: use logical properties everywhere.

Instead ofUse
margin-leftmargin-inline-start
padding-rightpadding-inline-end
left: 0inset-inline-start: 0
text-align: lefttext-align: start
border-leftborder-inline-start

Flexbox and grid already follow the document direction. If the codebase uses logical properties from day one, most of the RTL layout comes for free. Retrofitting a finished LTR site is where the real cost is.

A few things logical properties do not cover:

  • Directional icons. Arrows and chevrons that mean "next" or "back" must mirror. We mark them with a flip-rtl class and apply transform: scaleX(-1) under html[dir='rtl']. Icons that are not directional, like a search glass or a check mark, stay as they are.
  • Transforms and animations. translateX(4px) moves right in both directions, so hover nudges need an RTL counterpart.
  • Mixed-direction content. Email addresses, phone numbers, code and Latin product names inside Persian text need dir="ltr" or unicode-bidi: isolate, or their punctuation ends up on the wrong side.

Typography for Persian and Arabic

  • Never letter-space Arabic script. Persian and Arabic letters join. Adding letter-spacing breaks the joins and makes text look broken. The same goes for text-transform: uppercase, which does nothing useful. Wherever our design uses tracked, uppercase labels in English, we reset both for fa and ar.
  • Choose fonts designed for the script. We use Vazirmatn for Persian and Arabic body text and Estedad for Persian display type, both loaded with font-display: swap. Latin fonts with bolted-on Arabic glyphs rarely read well.
  • Increase line height. Arabic script has taller ascenders and deeper descenders. Headings that look right at 1.1 line height in English need around 1.4 in Persian.

Numbers and dates

Intl handles most of it with no library:

new Intl.NumberFormat('fa-IR').format(1405)       // "۱٬۴۰۵"
new Intl.DateTimeFormat('fa-IR-u-ca-persian', { dateStyle: 'long' })
  .format(new Date('2026-09-27'))                  // "۵ مهر ۱۴۰۵"
new Intl.DateTimeFormat('ar-u-ca-gregory', { dateStyle: 'long' })
  .format(new Date('2026-09-27'))                  // a Gregorian date in Arabic

Persian readers expect the Solar Hijri calendar, which -u-ca-persian provides. Saudi locales default to the Islamic calendar, so set the calendar explicitly when you mean Gregorian. For machine-readable output such as <time datetime> and structured data, always use ISO dates.

Normalize input as well as output. Users type Persian, Arabic and Latin digits interchangeably, and Persian text often contains the Arabic forms of "ی" and "ک". Normalize both before validating a phone number or running a search, or identical queries will miss each other.

Per-language SEO, not translated SEO

Translation is not localization, and it is definitely not SEO. For each language:

  • Research keywords in that language. Persian searchers do not phrase queries as translations of English ones. Our Persian service pages target different terms than the English ones, not translated terms.
  • Write titles and descriptions per language. Each locale file carries its own seo block.
  • Set og:locale correctly. Use fa_IR and ar_AR. A missing mapping silently falls back to en_US.
  • Keep structured data in the page's language. Names, descriptions and FAQ answers in JSON-LD should match the visible text of that locale.

A checklist before launch

  1. lang and dir on <html>, rendered on the server.
  2. Canonical plus reciprocal hreflang with x-default on every page.
  3. No hreflang pointing at URLs that 404 or redirect.
  4. A sitemap generated from real output, with alternates.
  5. Logical CSS properties throughout, and mirrored directional icons.
  6. No letter-spacing or uppercase transforms on Arabic-script text.
  7. Script-appropriate fonts and larger line heights.
  8. Locale-aware numbers and dates, and normalized input.
  9. Keyword research, titles and descriptions written per language.
  10. Each language checked in Search Console after launch.

If you are planning a multilingual or right-to-left product, our frontend development and SEO pages describe how we work, and this site is a live example of the result.

Frequently asked questions

Yes. Search engines index URLs, not languages. If languages share one URL and switch by cookie or browser setting, only one version will ever be indexed. Use a prefix such as /fa/, a subdomain, or a separate domain.

For most sites, subdirectories like /fa/ are simpler and keep all languages under one domain's authority. Separate country domains make sense when you run distinct businesses per market.

Declare dir: 'rtl' on the locale in @nuxtjs/i18n, set the dir attribute on the html element from the active locale, and write CSS with logical properties such as margin-inline-start so layouts mirror automatically.

Yes. hreflang tells search engines which version to show to which audience and stops the versions from competing with each other. Add x-default for everyone else.

Most Persian readers expect the Solar Hijri calendar. In JavaScript, Intl.DateTimeFormat with the fa-IR-u-ca-persian locale produces it without a library. Keep ISO dates in markup and structured data.