Adding i18n after the fact means hunting down every hardcoded string, rebuilding every date format, and retrofitting every layout for RTL. It's a full rewrite disguised as a feature.
Claude Code, with CLAUDE.md rules defined upfront, generates proper i18n architecture from the first line of code. Here's the exact setup.
Step 1: Define i18n Rules in CLAUDE.md
## Internationalization Rules
- All UI strings via i18n keys — no hardcoded text in components
- Dates via `Intl.DateTimeFormat` or `date-fns/locale` (not `.toLocaleDateString()`)
- Currency via `Intl.NumberFormat` with explicit currency code
- Plurals via ICU Message Format: `{count, plural, =0 {...} =1 {...} other {...}}`
- Message files: `messages/[lang]/[namespace].json`, camelCase keys
- TypeScript type-safe translations via next-intl
- RTL layout: use `margin-inline-start/end`, `border-inline-*`, `text-align: start`
- `dir="rtl"` on `<html>` for Arabic/Hebrew/Persian locales
Step 2: Message Files with ICU Plurals
messages/ja/common.json
{
"nav": {
"home": "ホーム",
"profile": "プロフィール",
"settings": "設定"
},
"dashboard": {
"welcome": "{name}さん、おかえりなさい",
"itemCount": "{count, plural, =0 {アイテムなし} =1 {1件のアイテム} other {{count}件のアイテム}}",
"lastLogin": "最終ログイン: {date}",
"balance": "残高: {amount}"
},
"errors": {
"notFound": "ページが見つかりません",
"serverError": "サーバーエラーが発生しました"
}
}
messages/ar/common.json
{
"nav": {
"home": "الرئيسية",
"profile": "الملف الشخصي",
"settings": "الإعدادات"
},
"dashboard": {
"welcome": "مرحباً بعودتك، {name}",
"itemCount": "{count, plural, =0 {لا توجد عناصر} =1 {عنصر واحد} other {{count} عناصر}}",
"lastLogin": "آخر تسجيل دخول: {date}",
"balance": "الرصيد: {amount}"
},
"errors": {
"notFound": "الصفحة غير موجودة",
"serverError": "حدث خطأ في الخادم"
}
}
Step 3: next-intl Configuration
i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { notFound } from "next/navigation";
const SUPPORTED_LOCALES = ["en", "ja", "ar", "ko", "zh"] as const;
type Locale = typeof SUPPORTED_LOCALES[number];
function isSupportedLocale(locale: unknown): locale is Locale {
return SUPPORTED_LOCALES.includes(locale as Locale);
}
export default getRequestConfig(async ({ locale }) => {
if (!isSupportedLocale(locale)) notFound();
const [common, auth] = await Promise.all([
import(`../messages/${locale}/common.json`),
import(`../messages/${locale}/auth.json`),
]);
return {
messages: {
common: common.default,
auth: auth.default,
},
};
});
next.config.ts
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
export default withNextIntl({
// your existing Next.js config
});
Step 4: Locale Detection Middleware
middleware.ts
import createMiddleware from "next-intl/middleware";
export default createMiddleware({
locales: ["en", "ja", "ar", "ko", "zh"],
defaultLocale: "en",
localeDetection: true, // respects Accept-Language header
localePrefix: "always",
});
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\..*).*)"],
};
Step 5: Type-Safe Component with Intl Formatting
app/[locale]/dashboard/page.tsx
import { useTranslations, useFormatter } from "next-intl";
interface DashboardProps {
user: { name: string };
itemCount: number;
lastLoginAt: Date;
balanceUsd: number;
}
export default function Dashboard({
user,
itemCount,
lastLoginAt,
balanceUsd,
}: DashboardProps) {
const t = useTranslations("common.dashboard");
const format = useFormatter();
return (
<main>
{/* ICU named interpolation — type-safe, no positional arguments */}
<h1>{t("welcome", { name: user.name })}</h1>
{/* ICU plural — automatically selects correct form per locale */}
<p>{t("itemCount", { count: itemCount })}</p>
{/* Intl.DateTimeFormat under the hood — locale-aware */}
<p>
{t("lastLogin", {
date: format.dateTime(lastLoginAt, {
year: "numeric",
month: "long",
day: "numeric",
}),
})}
</p>
{/* Intl.NumberFormat — currency symbol, decimal separator, all locale-correct */}
<p>
{t("balance", {
amount: format.number(balanceUsd, {
style: "currency",
currency: "USD",
}),
})}
</p>
</main>
);
}
Step 6: RTL Layout with Logical CSS Properties
Instead of margin-left/margin-right that you'd need to override per locale, use CSS logical properties that automatically flip for RTL.
/* ❌ Breaks in RTL — requires per-locale overrides */
.sidebar {
margin-left: 16px;
border-right: 1px solid #e5e7eb;
text-align: left;
}
/* ✅ Correct — flips automatically with dir="rtl" */
.sidebar {
margin-inline-start: 16px; /* left in LTR, right in RTL */
border-inline-end: 1px solid #e5e7eb;
text-align: start; /* left in LTR, right in RTL */
}
.card {
padding-inline: 24px; /* left+right padding, direction-aware */
border-start-start-radius: 8px; /* top-left in LTR, top-right in RTL */
}
Step 7: RTL dir Attribute on <html>
app/[locale]/layout.tsx
import { ReactNode } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
const RTL_LOCALES = new Set(["ar", "he", "fa", "ur"]);
interface LocaleLayoutProps {
children: ReactNode;
params: { locale: string };
}
export default async function LocaleLayout({
children,
params: { locale },
}: LocaleLayoutProps) {
const messages = await getMessages();
const dir = RTL_LOCALES.has(locale) ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
dir="rtl" on the root <html> element propagates to all child elements. Combined with CSS logical properties, the layout mirrors correctly without a single conditional.
Summary
| Concern | Solution |
|---|---|
| Hardcoded strings | next-intl useTranslations with namespace keys |
| Plurals | ICU Message Format {count, plural, ...}
|
| Date formatting |
useFormatter().dateTime() (Intl.DateTimeFormat) |
| Currency | useFormatter().number({ style: "currency" }) |
| RTL layout | CSS logical properties (margin-inline-*, border-inline-*) |
| RTL direction |
dir attribute on <html>, driven by RTL_LOCALES set |
| Type safety | next-intl + TypeScript — wrong key names are compile errors |
Define the i18n rules in CLAUDE.md. Claude Code generates compliant components from the start — no retroactive refactoring required.
Code Review Pack (¥980) includes /code-review prompt for catching hardcoded strings, missing locale support, and RTL layout bugs. 👉 https://prompt-works.jp
Top comments (0)