DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Internationalization with Claude Code: next-intl, Type-Safe Translations, and RTL Support

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
Enter fullscreen mode Exit fullscreen mode

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": "サーバーエラーが発生しました"
  }
}
Enter fullscreen mode Exit fullscreen mode

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": "حدث خطأ في الخادم"
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
  };
});
Enter fullscreen mode Exit fullscreen mode

next.config.ts

import createNextIntlPlugin from "next-intl/plugin";

const withNextIntl = createNextIntlPlugin("./i18n/request.ts");

export default withNextIntl({
  // your existing Next.js config
});
Enter fullscreen mode Exit fullscreen mode

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|.*\..*).*)"],
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)