Building a Bilingual Blog with Astro

Building a Bilingual Blog with Astro

Most blog templates assume one language, one direction, one set of typographic rules. If you want Arabic and English in the same site — not just translated routes, but posts that can be in either language — you need to think differently.

Here’s how I built it. No plugins, no i18n libraries. Just Astro’s content collections and some careful CSS.

The core idea

Every post declares its own language:

---
title: "مرحبا"
lang: ar
dir: rtl
---

The lang and dir fields propagate all the way up to the <html> element. This means the entire page — not just the content area — respects the post’s direction. Screen readers, browser find-in-page, and CSS logical properties all work correctly because the document’s root declares the right direction.

Content collection schema

Astro’s content collections give you type-safe frontmatter. The schema enforces the contract:

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.coerce.date(),
    lang: z.enum(['en', 'ar']).default('en'),
    dir: z.enum(['ltr', 'rtl']).optional(),
    tags: z.array(z.string()).default([]),
  }),
});

If dir isn’t specified, the layout infers it from lang. Arabic defaults to RTL; everything else defaults to LTR. You can override this — maybe you have an English post with a long Arabic quote and want RTL for the whole page.

The layout chain

The base layout accepts lang and dir as props:

<html lang={lang} dir={dir}>

The blog post layout computes the direction if it isn’t explicit:

const textDir = dir || (lang === 'ar' ? 'rtl' : 'ltr');

This two-layer approach keeps things clean. The base layout doesn’t know about blog posts. The blog layout doesn’t hardcode directions. Each layer does one job.

CSS: logical properties are your friend

The biggest mistake in bilingual CSS is using left and right. When your page flips from LTR to RTL, every margin-left becomes wrong.

CSS logical properties solve this:

/* Instead of this: */
.post-nav { margin-left: 2rem; }

/* Use this: */
.post-nav { margin-inline-start: 2rem; }

margin-inline-start means “the start side of the inline axis” — left in LTR, right in RTL. The browser handles the flip. You write the style once.

The same applies to:

  • padding-inline-start / padding-inline-end
  • border-inline-start / border-inline-end
  • text-align: start instead of text-align: left
  • inset-inline-start instead of left for positioned elements

Fonts: one family for both scripts

I use Tajawal for everything — Arabic and English. It’s designed for both scripts, so the visual weight and rhythm stay consistent when languages mix on the same page. Load it locally to avoid layout shift:

@font-face {
  font-family: 'Tajawal';
  src: url('/fonts/tajawal-400.woff2') format('woff2');
  font-weight: 400;
  font-display: swap;
}

If you use separate fonts for each script, you’ll fight with line-height mismatches and baseline alignment. A shared family avoids that entirely.

Date formatting

Dates need to match the post language. Arabic readers expect Arabic-Indic numerals and Hijri month names — or at least Arabic month names with Western numerals. JavaScript’s Intl.DateTimeFormat handles this:

export function formatDate(date: Date, lang: string = 'en'): string {
  const locale = lang === 'ar' ? 'ar-SA' : 'en-US';
  return date.toLocaleDateString(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  });
}

Small thing, but it signals that the Arabic content isn’t an afterthought.

What I didn’t use

  • No i18n routing library. Every post lives at /blog/slug regardless of language. The language is a property of the content, not the URL structure.
  • No translation files. The blog doesn’t have translated UI strings. The header and footer are English. This is a bilingual blog, not a localized app — the chrome stays in the primary language.
  • No separate content directories. Arabic and English posts live in the same folder. They sort by date together. A reader browsing the home page sees both languages interleaved, which is intentional — this is a bilingual space, not two separate blogs.

The tradeoffs

This approach optimizes for simplicity. The cost:

  1. No URL-based language filtering. You can’t go to /ar/ to see only Arabic posts. The tag system handles filtering instead — you could tag Arabic posts with an arabic tag.
  2. Mixed-language feeds. The RSS feed includes both languages. Some readers might not want that. A future improvement could be language-specific feeds.
  3. Navigation stays in English. For a truly bilingual UI, you’d need to localize the header, which means either duplicating it or adding a translation layer.

For a personal blog, these tradeoffs are fine. For a commercial product, you’d want more structure.

The lesson

Bilingual support isn’t a feature you bolt on. It’s a decision you make at the layout level, enforce in the schema, and express through CSS. Get those three layers right — document direction, content types, logical properties — and everything else follows.