← All articles

Astro i18n: Build an Arabic and English Website with RTL

Build an Astro site with English default routes, Arabic localized content, RTL layout, translated collections, canonical URLs, hreflang, and a sitemap.

Bakry Abdalsalam builds websites, applications, integrations, and WordPress products. Bakry Dev Hub documents the technical decisions behind this work.

THE SHORT VERSION

Model language as content data, pair translations with a stable key, generate English and Arabic routes at build time, and make lang, dir, canonical, and hreflang derive from the same route source.

A bilingual Astro site needs more than translated navigation. Routing, content identity, document direction, metadata, alternate URLs, feeds, and missing translations must follow one consistent model.

This guide uses English as the default language without an /en/ prefix and Arabic under /ar/. That produces /blog/example/ and /ar/blog/example/, which is a clean fit for an English-first site.

Configure Astro i18n routing

Set the site origin, supported locales, default locale, and trailing-slash policy:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://example.com',
  trailingSlash: 'always',
  output: 'static',
  i18n: {
    locales: ['en', 'ar'],
    defaultLocale: 'en',
    routing: {
      prefixDefaultLocale: false,
    },
  },
  integrations: [
    sitemap({
      i18n: {
        defaultLocale: 'en',
        locales: {
          en: 'en-US',
          ar: 'ar',
        },
      },
    }),
  ],
});

With prefixDefaultLocale: false, Astro’s documented URL strategy keeps the default locale unprefixed. Decide this before launch; changing it later requires redirects for every indexed URL.

Keep UI translations typed

A small dictionary works well for navigation and repeated labels:

export type Locale = 'en' | 'ar';

export const ui = {
  en: {
    dir: 'ltr',
    nav: { home: 'Home', blog: 'Articles' },
    routes: { home: '/', blog: '/blog/' },
  },
  ar: {
    dir: 'rtl',
    nav: { home: 'الرئيسية', blog: 'المقالات' },
    routes: { home: '/ar/', blog: '/ar/blog/' },
  },
} as const;

Do not mix visible strings, route paths, and direction checks across many components. Derive them from one locale value so an Arabic page cannot accidentally render English metadata or dir="ltr".

Model translated articles explicitly

Use Astro content collections for article metadata and validation:

const articles = defineCollection({
  loader: glob({
    base: './src/content/articles',
    pattern: '**/*.{md,mdx}',
  }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    locale: z.enum(['en', 'ar']),
    routeSlug: z.string(),
    translationKey: z.string(),
    publishedAt: z.coerce.date(),
    draft: z.boolean().default(false),
  }),
});

Store content under language directories:

src/content/articles/
├── en/astro-i18n-arabic-english-rtl.md
└── ar/astro-i18n-arabic-english-rtl.md

translationKey pairs meaning; routeSlug controls the URL. Keeping the same English slug in both languages avoids encoded Arabic paths, but the two fields should remain separate so routing can evolve without losing translation identity.

Arabic content should be localized, not mechanically mirrored. Keep commands and code in their original syntax while explaining decisions naturally in Arabic.

Generate static article routes

For English:

---
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const articles = await getCollection(
    'articles',
    ({ data }) => data.locale === 'en' && !data.draft,
  );

  return articles.map((article) => ({
    params: { slug: article.data.routeSlug },
    props: { article },
  }));
}
---

Place that route at src/pages/blog/[slug].astro. Repeat the filter for Arabic at src/pages/ar/blog/[slug].astro.

Static generation keeps article HTML available without client JavaScript and makes missing route collisions fail during the build when you validate them.

Set lang and dir on the document

The layout must set both attributes:

---
const { locale = 'en' } = Astro.props;
const dir = locale === 'ar' ? 'rtl' : 'ltr';
---

<html lang={locale} dir={dir}>
  <body>
    <slot />
  </body>
</html>

Use CSS logical properties so components adapt without separate mirrored styles:

.article-card {
  padding-inline: 1.25rem;
  border-inline-start: 3px solid var(--accent);
}

.article-content pre,
.article-content code {
  direction: ltr;
  text-align: left;
}

Test mixed Arabic and English text, numbers, punctuation, inline code, tables, and long commands. Direction is a content behavior, not just text alignment.

Generate canonical and hreflang together

Every page needs a self-canonical plus language alternates:

---
const canonical = new URL(Astro.url.pathname, 'https://example.com');
const alternatePath =
  locale === 'en'
    ? `/ar${Astro.url.pathname}`
    : Astro.url.pathname.replace(/^\/ar(?=\/)/, '') || '/';
const alternate = new URL(alternatePath, 'https://example.com');
---

<link rel="canonical" href={canonical} />
<link rel="alternate" hreflang={locale} href={canonical} />
<link
  rel="alternate"
  hreflang={locale === 'en' ? 'ar' : 'en'}
  href={alternate}
/>
<link
  rel="alternate"
  hreflang="x-default"
  href={locale === 'en' ? canonical : alternate}
/>

Only output an alternate when the paired article exists. Find it by translationKey, not by assuming every slug has a translation. A false hreflang pointing to a 404 is worse than omitting it.

The canonical for an Arabic page must remain Arabic; hreflang expresses equivalence without collapsing languages into one indexable URL.

Localize structured data and navigation

Set inLanguage in BlogPosting or Article JSON-LD, use the localized title and description, and point mainEntityOfPage at the self-canonical.

The language switcher should link directly to the paired page, not to the other homepage. When no translation exists, either omit the control or clearly send the reader to the localized archive.

Generate separate RSS feeds for each language and ensure the sitemap contains both routes. The sitemap is a discovery mechanism; it does not replace visible links or hreflang.

Validate before deployment

Automate checks for:

  • Unique (locale, routeSlug) values.
  • Exactly one page per translationKey and locale.
  • Self-canonical URLs using the configured origin.
  • Alternates that return 200.
  • Correct <html lang> and dir.
  • No draft entries in routes, RSS, or sitemap.
  • Internal links that stay in the current language where a translation exists.

Then review mobile layouts with real Arabic text. A page can be technically RTL and still have awkward icon order, code overflow, or unreadable mixed-direction metadata.

The Astro content architecture guide covers scalable collections, while the Astro performance budget keeps the multilingual layer lightweight.

Common mistakes

Avoid deriving Arabic URLs with string replacement in many components, canonicalizing Arabic to English, switching languages to the homepage, loading a client-side translation framework for static content, and forcing code blocks into RTL.

One route model should drive page generation, language switching, canonical, hreflang, RSS, and sitemap. That consistency is the real i18n feature.

Frequently asked questions

Can Arabic and English articles use the same slug?

Yes. The locale prefix keeps their URLs distinct, while a shared English slug avoids encoded paths. Pair the content with a separate translation key rather than relying on the slug alone.

Does a static Astro site need a client-side i18n library?

Not for static translated pages. Build-time routes, content collections, and a small UI dictionary usually provide localized HTML with less JavaScript.

Official references

Have a question about this guide or an idea for a technical collaboration? Contact Bakry through the Dev Hub.

End of field note.