Claude Codeでnext-intlを多言語化 — ICUの複数形を検査して移す
Next.jsの固定文言をnext-intlへ移す流れをClaude Codeに任せる手順です。ロケールルーティングの土台、messages JSON、ICUのplural/selectを、検査スクリプトとhookで確かめます。
Next.jsの画面に直書きした日本語を、next-intlで英語・日本語などの複数ロケールに切り替えられる形へ移します。Claude Codeに任せやすいのはファイルの移動と文言の切り出しです。一方で、複数形(ICUのplural)は言語ごとに必要な分岐が違い、書いてあるだけでは誤りに気づきにくい部分です。この記事は、移行を3つの段に分け、最後に検査スクリプトとhookで「通るまで終わらない」状態にするところまでを扱います。
最初の依頼は実装でなくplan modeで棚卸しにする
多言語化は、触るファイルが多い割に1つ1つの差分は小さい作業です。いきなり「next-intlに移行して」と頼むと、ルーティング、設定、文言の切り出しが1つの差分に混ざります。そこで最初の1回はplan modeで読むだけにします。
claude --permission-mode planplan modeでは、Claudeがファイルを読んで計画を出すだけで、承認するまで編集しません。ステータスバーに ⏸ plan mode on と出ます。セッション中は Shift+Tab でも切り替えられます。
最初のプロンプトでは、棚卸しの観点を指定します。例えば次のような依頼です。
src/app 配下の画面について、次を一覧にして。編集はしない。
1. 日本語の固定文言が直書きされているコンポーネント
2. 数や件数を文字列連結で埋めている箇所(「3件」「あと2日」など)
3. 男性・女性・状態などで文言を分けている条件分岐
4. next/link と next/navigation を直接使っている箇所2と3が、後でICUのpluralとselectに変わる箇所です。ここを先に拾っておくと、文言の切り出し時に「連結をやめて引数にする」判断が抜けません。計画に目を通して承認するか、順序を直してから実装に入ります。
ロケールルーティングの土台を作る
next-intlのロケールルーティングは、[locale] という最上位の動的セグメントを使います。/en/about のような接頭辞方式と、en.example.com/about のようなドメイン方式を選べます。土台になるのは次の4ファイルです。
src/i18n/routing.ts: 対応ロケールとデフォルトを定義するsrc/proxy.ts: ロケールを判定して振り分けるsrc/i18n/navigation.ts: ロケールを考慮したLinkやredirectを作るsrc/i18n/request.ts: リクエストごとのロケールとmessagesを返す
最小のrouting定義は次の形です。
// src/i18n/routing.ts
import {defineRouting} from 'next-intl/routing';
export const routing = defineRouting({
locales: ['ja', 'en'],
defaultLocale: 'ja'
});proxy.ts はNext.js 16より前は middleware.ts という名前でした。使っているNext.jsのバージョンで名前が変わるので、Claudeに依頼する前に package.json のバージョンを伝えておきます。matcherには api、trpc、_next、_vercel と、ドットを含むパス(favicon.ico など)を除く式を書きます。/users/jane.doe のようにドットが正当なURLがある場合は、matcherに個別の項目を足す必要があります。
request.ts では、next/root-params からロケールを読めます。これはNext.js 16.3以降なら既定で使えて、それより前は experimental.rootParams で有効にします。ロケールが routing.locales に無ければ notFound() を呼ぶ形です。
最後に、既存のレイアウトとページを src/app/[locale]/ の下へ移します。ここは機械的な移動なので、Claude Codeの得意な部分です。移動後は、next/link の Link を src/i18n/navigation.ts が出す Link に置き換えます。置き換えを忘れると、リンクがロケール接頭辞を失います。App Router自体への移行がまだなら、先にルート単位でApp Routerへ移す手順を済ませてから進めると、差分が混ざりません。
固定文言をmessages JSONへ切り出す
土台が動いたら、文言を messages/ja.json に移します。構造は画面や機能ごとの名前空間で入れ子にします。
{
"Cart": {
"title": "カート",
"empty": "カートは空です"
}
}コンポーネント側は、サーバーコンポーネントなら useTranslations('Cart')、asyncなコンポーネントなら getTranslations を使います。
規約はCLAUDE.mdに書いておく
文言の切り出しは、キー名の付け方が揺れやすい作業です。next-intlのドキュメントは、useTranslations を使うプロジェクトでは CLAUDE.md や AGENTS.md に規約を書くことを勧めていて、例も載っています。これをそのまま使うのではなく、プロジェクトに合わせて次のように書き換えます。
## 多言語化(next-intl)
- ユーザーに見える文言は useTranslations / getTranslations で出す。直書きしない。
- 新しい文言は messages/ja.json に足す。他言語は翻訳担当が扱うので触らない。
- キーは title や description のように短く役割で付ける。原文をキーにしない。
- 件数・名前の差し込みは文字列連結でなく ICU の引数にする。
- 件数で文言が変わるものは plural、状態で変わるものは select を使う。最後の2行は、引数を連結に戻されないための規約です。CLAUDE.mdは指示であって強制ではないため、守られたかどうかは、検査スクリプトで確かめます。AGENTS.mdの扱いはNext.js側にも仕組みがあるので、併用するときは置き場所を1か所に決めておきます。
useExtractedという選択肢
next-intlには、文言をコンポーネントの中に書き、カタログへの抽出を任せる useExtracted もあります。ドキュメントは、エージェント向けの利点として、編集が1ファイルで済むこと、キー名を考えなくてよいこと、カタログが自動で同期されることを挙げています。既存のmessages JSONを持つ移行では useTranslations のほうが差分が小さく、新規に作る画面では useExtracted を検討できます。どちらに寄せるかはCLAUDE.mdに1行で書いておくと、画面ごとに混ざりません。
複数形は言語ごとに必要な分岐が違う
ICUのpluralは、件数に応じて文言を切り替える構文です。next-intlのドキュメントにある基本形は次のとおりです。
{
"message": "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}."
}# は数値として整形された件数に置き換わり、=0 のように値を直接指定する書き方は、カテゴリ名より優先されます。選べるカテゴリは zero、one、two、few、many、other の6つで、other は必須です。どれが使われるかは Intl.PluralRules で決まります。
言語ごとの違いは大きく、ドキュメントによると英語は2形、中国語は1形、アラビア語は6形です。日本語も1形です。手元のNode.js(v20.18.0)で、各ロケールのカテゴリを調べると次のようになりました。
node -e "for (const l of ['en','ja','ru']) console.log(l, new Intl.PluralRules(l).resolvedOptions().pluralCategories)"en [ 'one', 'other' ]
ja [ 'other' ]
ru [ 'few', 'many', 'one', 'other' ]英語から日本語へ移すとき、one {# item} をそのまま one {#点} と訳しても、日本語では one が選ばれることがありません。害は出ませんが、不要な分岐が残ります。逆にロシア語では one と other だけの英語型で訳すと、few や many に当たる数でも other の形が使われ、語尾が合わなくなります。
ドキュメントは、翻訳をエージェントに任せる場面で「対象言語の複数形の規則を推測して微妙な誤りが出る」ことを弱点に挙げています。文面は自然に見えるため、読んでも気づきにくい種類の誤りです。
selectは列挙値で文言を切り替えます。{gender, select, female {She} male {He} other {They}} のように書き、other が必須です。値には英数字とアンダースコアだけが使えるので、en-GB のようなロケールを渡すときは - を _ に変えてから渡します。
検査スクリプトで言語間のずれを機械的に止める
そこで、messages JSONを機械的に検査するスクリプトを置きます。見る項目は4つです。
- ICUとして構文が正しい
- pluralに
otherがある - pluralのカテゴリが、そのロケールに存在する
- 日本語側の引数が、原文(ここでは
en)と過不足なく対応している
ICUのパーサーには @formatjs/icu-messageformat-parser を開発依存として入れます。
npm i -D @formatjs/icu-messageformat-parser// scripts/check-messages.mjs
import {readFileSync, readdirSync} from 'node:fs';
import {parse, TYPE} from '@formatjs/icu-messageformat-parser';
const dir = 'messages';
const source = 'en';
const errors = [];
const warnings = [];
const flatten = (obj, prefix = '') =>
Object.entries(obj).flatMap(([k, v]) =>
typeof v === 'object' ? flatten(v, `${prefix}${k}.`) : [[`${prefix}${k}`, v]]
);
// 引数名と plural のカテゴリを集める
function walk(nodes, info) {
for (const n of nodes) {
if ([TYPE.argument, TYPE.number, TYPE.date, TYPE.time].includes(n.type)) {
info.args.add(n.value);
}
if (n.type === TYPE.plural || n.type === TYPE.select) {
info.args.add(n.value);
if (n.type === TYPE.plural) {
info.plurals.push({keys: Object.keys(n.options)});
}
for (const opt of Object.values(n.options)) walk(opt.value, info);
}
if (n.type === TYPE.tag) walk(n.children, info);
}
}
function inspect(message) {
const info = {args: new Set(), plurals: []};
walk(parse(message), info);
return info;
}
const load = (loc) =>
new Map(flatten(JSON.parse(readFileSync(`${dir}/${loc}.json`, 'utf8'))));
const locales = readdirSync(dir)
.filter((f) => f.endsWith('.json'))
.map((f) => f.replace('.json', ''));
const base = load(source);
for (const loc of locales) {
const msgs = load(loc);
const cats = new Intl.PluralRules(loc).resolvedOptions().pluralCategories;
for (const [key, text] of msgs) {
let info;
try { info = inspect(text); }
catch (e) { errors.push(`${loc}:${key} ICU構文エラー ${e.message}`); continue; }
for (const p of info.plurals) {
if (!p.keys.includes('other')) errors.push(`${loc}:${key} plural に other がない`);
const unknown = p.keys.filter((k) => !k.startsWith('=') && !cats.includes(k));
if (unknown.length) errors.push(`${loc}:${key} ${loc} に無いカテゴリ ${unknown}`);
const missing = cats.filter((c) => !p.keys.includes(c));
if (missing.length) warnings.push(`${loc}:${key} 不足カテゴリ ${missing}`);
}
if (loc === source) continue;
if (!base.has(key)) { errors.push(`${loc}:${key} 原文に無いキー`); continue; }
const want = inspect(base.get(key)).args;
const miss = [...want].filter((a) => !info.args.has(a));
const extra = [...info.args].filter((a) => !want.has(a));
if (miss.length || extra.length)
errors.push(`${loc}:${key} 引数の不一致 不足=[${miss}] 余剰=[${extra}]`);
}
if (loc !== source)
for (const key of base.keys())
if (!msgs.has(key)) errors.push(`${loc}:${key} 未翻訳`);
}
warnings.forEach((w) => console.log('WARN ', w));
errors.forEach((e) => console.log('ERROR', e));
process.exit(errors.length ? 1 : 0);英語・日本語・ロシア語の3ファイルを意図的に誤った形で作って走らせると、次の出力になりました(@formatjs/icu-messageformat-parser 3.5.22、Node.js v20.18.0)。
WARN ru:Cart.items 不足カテゴリ few,many
ERROR ja:Cart.items ja に無いカテゴリ one
ERROR ja:Cart.owner 引数の不一致 不足=[gender] 余剰=[]
ERROR ru:Cart.owner 引数の不一致 不足=[name] 余剰=[title]見つかった4件は、順に「ロシア語の複数形が英語型のまま」「日本語に不要な one」「日本語で gender の分岐が消えている」「ロシア語で {name} が {title} に変わっている」です。3件目は、日本語では性別で文言を分けない設計なら正しい訳です。スクリプトは正誤を決めず、差を見せるだけなので、意図した差かどうかは人が判断します。
警告(WARN)と誤り(ERROR)を分けたのは、few や many が足りなくても other に落ちて動くからです。終了コードは誤りがあるときだけ1にします。
Stop hookで「検査が通るまで終わらない」にする
スクリプトがあっても、Claudeが走らせ忘れれば意味がありません。CLAUDE.mdの指示は守られるとは限らない一方、hookは必ず実行されます。公式のベストプラクティスも、毎回確実に行いたいことはCLAUDE.mdでなくhookに置く考え方を示しています。
Stop はClaudeが応答を終えるときのイベントで、exit code 2を返すと、Claudeは止まらずに作業を続けます。.claude/settings.json には次のように書きます。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node scripts/check-messages.mjs >&2 || exit 2"
}
]
}
]
}
}検査の出力は標準エラーに回してあるので、失敗時の内容がそのままClaudeに戻ります。「jaに無いカテゴリone」といった行を見て、Claudeが自分で直しに戻る流れです。この構成はi18nextのJSON翻訳でも使った型で、キーと変数の過不足を止める部分は共通です。next-intlの場合はICUの構文とpluralのカテゴリまで見る点が加わります。
人が直すしかない失敗(原文側の誤りなど)が残ると、Stopで戻り続けます。そうした箇所は、スクリプト側で除外する仕組みを先に決めておきます。
型でキーの打ち間違いも止める
キーの存在確認は、TypeScriptでも補えます。next-intlは AppConfig を拡張すると、Locale と Messages を型として使えます。
// global.d.ts
import {routing} from '@/i18n/routing';
import messages from './messages/ja.json';
declare module 'next-intl' {
interface AppConfig {
Locale: (typeof routing.locales)[number];
Messages: typeof messages;
}
}こうすると、t('titel') のような存在しないキーが型エラーになります。tsc --noEmit をStop hookの検査に加えれば、キー名の誤りも同じ入口で止まります。
翻訳そのものは分けて考える
ここまでの流れは、移行の構造を整える作業です。他言語への翻訳は別の問題です。next-intlのドキュメントは、カタログ単体では使われ方が分からず意図を外しやすいこと、用語が揺れやすいこと、多数のロケールを編集すると文脈が膨らむことを挙げ、汎用のエージェントは翻訳が得意でないと述べています。そのうえで、ソースコードからメッセージの使用文脈を付けて検証する @eloqnt/cli を併用する構成を紹介しています。
この記事のスクリプトは、翻訳の質は見ず、構造の整合だけを見ます。訳文の質まで必要なら、次の分担が考えられます。
| 工程 | 担当 | 確かめ方 |
|---|---|---|
| 固定文言の切り出し・ルーティング移行 | 担当Claude Code | 確かめ方plan modeでの計画、tsc、検査スクリプト |
| 原文(ja)の複数形・差し込みの設計 | 担当Claude Code + 人 | 確かめ方検査スクリプトの差分を人が見る |
| 他言語の訳文 | 担当翻訳担当や翻訳管理サービス | 確かめ方レビュー、@eloqnt/cli などの専用の検査 |
訳文を作る工程だけを別に扱いたい場合は、poファイルのプレースホルダを壊さない翻訳も同じ考え方です。
つまずきやすい点
- proxyのmatcherが広すぎる: 画像やAPIにもロケール接頭辞が付き、404になります。
api、_next、ドット入りのパスを除く式を確認します。 Linkの取り違え:next/linkのままだとロケールが落ちます。src/i18n/navigation.tsのLinkに統一します。- 日本語に
oneを訳し足す: 害はありませんが、検査では誤りに数えます。日本語はotherだけで足ります。 - selectの値にハイフン:
en-GBのような値は使えません。en_GBに置き換えて渡します。 - 波括弧の文字そのもの: 引数の区切りと見なされます。文言に波括弧を出したい場合は、シングルクォートで囲みます。
状況別の進め方
既存のmessages JSONがあり、i18nextなど別のライブラリから移る場合は、plan modeで「キーの対応表」を出させてから切り替えると、差分が小さく済みます。画面数が少なく新規に近いなら、useExtracted で書いてカタログを任せる道もあります。ロケールが増える見込みなら、先に検査スクリプトとStop hookを入れ、ロケールを足すたびに同じ入口で止める形にします。
まとめ
多言語化の移行で、Claude Codeに任せやすいのはルーティングの土台と文言の切り出しで、任せきりにしにくいのが複数形などの言語ごとの規則です。plan modeで棚卸し、CLAUDE.mdに規約、Stop hookに検査という分担にすると、規則の誤りが「終わらない」形で現れます。まず日本語と英語の2ロケールで検査が通る状態を作り、ロケールを増やす段で、Intl.PluralRules が返すカテゴリに合わせて訳文の分岐を直す順が無理のない進め方です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- code.claude.com/docs/en/common-workflows
- code.claude.com/docs/en/hooks
- code.claude.com/docs/en/best-practices
- next-intl.dev/docs/getting-started
- next-intl.dev/docs/getting-started/app-router
- next-intl.dev/docs/routing/setup
- next-intl.dev/docs/usage/messages
- next-intl.dev/docs/workflows/typescript
- next-intl.dev/docs/workflows/agents