Localization is more than string translation. You must handle routing, pluralization, dates/numbers, and directionality. This guide shows production patterns for Next.js App Router and plain React: locale routing, server‑first translations, RTL support with logical CSS, bidirectional text edge cases, and testing strategies.
You’ll get: a locale segment layout, middleware detection,
Intl.*formatting,dir/:dir()patterns, flipping icons, and Playwright tests for RTL & 200% zoom.
Table of Contents
- Locale routing & detection
- Loading translations
- Formatting with
Intl - Directionality (
dir) & logical CSS - Icons, illustrations & charts in RTL
- Bidi (bidirectional) text edge cases
- Forms & input carets
- Testing: Playwright & visual diffs
- Reference layout
- FAQ
Locale routing & detection
Use a locale segment in the App Router:
app/
[locale]/
layout.tsx
page.tsx
Middleware can redirect / → /ar based on Accept-Language with a whitelist.
// middleware.ts (sketch)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const locales = ["en", "ar", "fr"];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (pathname === "/") {
const pref = req.headers.get("Accept-Language") || "";
const chosen = locales.find((l) => pref.includes(l)) || "en";
return NextResponse.redirect(new URL(`/${chosen}`, req.url));
}
return NextResponse.next();
}Set <html lang dir> per locale:
// app/[locale]/layout.tsx
export default function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const dir = params.locale === "ar" || params.locale === "he" ? "rtl" : "ltr";
return (
<html lang={params.locale} dir={dir}>
<body>{children}</body>
</html>
);
}Loading translations
Prefer server‑side loading so strings render with HTML.
- Store messages as JSON per locale.
- For dynamic content, stream with
<Suspense>.
// lib/i18n.ts
export async function loadMessages(locale: string) {
return (await import(`../messages/${locale}.json`)).default;
}Pass messages via props/context to client islands if needed.
Formatting with Intl
Use Intl.DateTimeFormat, Intl.NumberFormat, Intl.PluralRules for locale‑aware output.
export function formatCurrency(
amount: number,
locale: string,
currency: string,
) {
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(
amount,
);
}Pluralization:
const pr = new Intl.PluralRules(locale);
const cat = pr.select(count); // "one" | "other" | etc.Directionality (dir) & logical CSS
Set document direction via <html dir> and prefer logical properties so layouts flip automatically.
.card {
padding-inline: 1rem;
border-inline-start: 1px solid var(--border);
}
:dir(rtl) .chevron {
transform: scaleX(-1);
} /* flip icons */Avoid hardcoded left/right; use inline-start/end and block-start/end.
Icons, illustrations & charts in RTL
- Mirror chevrons/arrows. Keep play/volume icons LTR (industry convention).
- For charts, swap default origin if it encodes direction (e.g., waterfall). Always keep numerals readable; switch locale for axis formatting.
Bidi (bidirectional) text edge cases
When mixing RTL text with LTR codes/URLs:
- Wrap LTR fragments with
<bdi dir="ltr">code-123</bdi>. - Use
unicode-bidi: plaintexton inputs so pasted text flows correctly. - Avoid inserting punctuation between opposite‑direction fragments without bidi isolation.
<p><bdi dir="ltr">https://example.com</bdi> تم النسخ</p>Forms & input carets
For numeric/technical fields, force LTR:
<input inputMode="numeric" dir="ltr" className="text-end" />Keep validation/error messages localized and maintain focus order.
Testing: Playwright & visual diffs
import { test, expect } from "@playwright/test";
test("RTL layout and strings", async ({ page }) => {
await page.goto("/ar");
const dir = await page.getAttribute("html", "dir");
expect(dir).toBe("rtl");
await expect(page).toHaveScreenshot("home-ar-rtl.png");
});- Test light/dark + RTL snapshots.
- Increase zoom to 200% to reveal overflow issues.
- Keyboard test: Arrow keys in RTL may map opposite for carousels—verify behavior.
Reference layout
app/
[locale]/
layout.tsx
page.tsx
api/
lib/
i18n.ts
messages/
en.json
ar.json
public/
flags/
FAQ
Do I need separate fonts for RTL?
Often yes—choose fonts with good Arabic/Hebrew support; use next/font with subsets.
How do I handle mixed numerals?
Use Intl.NumberFormat with the correct locale; consider forcing LTR for account numbers.
What library should I use?
Any established i18n library works. With the App Router, server‑first loading of messages is key; client libraries manage interpolation and hooks.
Next steps
- Add a
[locale]segment and middleware detection. - Switch to logical CSS and audit left/right uses.
- Snapshot RTL screens and fix flipped icons and overflow.
