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:
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.
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:
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:
: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.