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-endborder-inline-start/border-inline-endtext-align: startinstead oftext-align: leftinset-inline-startinstead ofleftfor 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/slugregardless 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:
- 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 anarabictag. - Mixed-language feeds. The RSS feed includes both languages. Some readers might not want that. A future improvement could be language-specific feeds.
- 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.