Skip to content

A bilingual (EN/FA) Next.js 16 site without an i18n library

How this site serves English and Persian with RTL, separate fonts and typed dictionaries — using only the App Router, proxy.ts and about 100 lines of code.

Published 1 min read

This site is available in English and Persian. Persian is right-to-left, uses a different font and needs dates in the Persian calendar. I expected to need a library. I didn't.

1. A locale segment as the root layout

Everything public lives under app/[locale], and that folder holds the root layout — so it can set <html lang dir> per request:

tsx
export default async function LocaleLayout({ children, params }: LayoutProps<"/[locale]">) {
  const locale = assertLocale((await params).locale);
  return (
    <html lang={locale} dir={locale === "fa" ? "rtl" : "ltr"}>
      <body>{children}</body>
    </html>
  );
}

2. proxy.ts picks the language

In Next.js 16 middleware.ts is now proxy.ts. Mine only does one thing: if the path has no locale, redirect to the visitor's preferred one — a cookie first, then Accept-Language, then English.

typescript
export function proxy(request: NextRequest) {
  const first = request.nextUrl.pathname.split("/")[1];
  if (isLocale(first)) return NextResponse.next();
  const url = request.nextUrl.clone();
  url.pathname = `/${preferredLocale(request)}${request.nextUrl.pathname}`;
  return NextResponse.redirect(url);
}

3. Typed dictionaries

UI strings are plain TypeScript objects. The Persian dictionary is typed against the English one, so a missing translation is a compile error, not a production surprise:

typescript
const fa: Dictionary = {
  nav: { home: "خانه", projects: "پروژه‌ها" /* … */ },
};

4. Content with fallbacks

Content fields are stored as { en, fa? }. One helper renders the right one and falls back to English when a translation doesn't exist yet — so I can publish in English first and translate later.

5. RTL without fighting CSS

Use Tailwind's logical utilities — ms-4, pe-2, start-0, text-start — and layouts flip automatically. Code blocks are the exception: they must stay left-to-right inside an RTL page:

css
:lang(fa) :is(pre, code) {
  direction: ltr;
  unicode-bidi: isolate;
}

That's the whole setup. No runtime i18n library, zero client JavaScript for translations, and fully static pages.