next-intlでWebアプリを多言語化:複数形・通貨・hreflangの実装
Reactアプリのi18nを実装で学ぶ。next-intlのメッセージ管理、複数形・日付・通貨の地域化、言語切替、hreflangとロケール検出まで。10言語運用の経験つき。
「英語版も用意して」と言われて、僕は翻訳ファイルを1枚足しました。3日後、英語ページだけ価格が「¥0」と表示されていることに気づきました。
原因はくだらない。日本語の文字列を英語に差し替えただけで、通貨と桁区切りはハードコードのまま放置していたんです。Intl.NumberFormat を一行使えば済んだ話でした。
多言語化(i18n)って、文字を訳す作業だと思われがちです。でも実際にハマるのは、複数形の「1 item / 2 items」、日付の「2026/3/24 と Mar 24, 2026」、通貨の桁区切り、そして検索エンジンに「これは英語版です」と伝える hreflang。このブログ自体を10言語で出している僕が、踏んだ地雷ごと実装で解説します。
この記事の要点
- i18nは翻訳ではなく「文字列の外出し + 地域化 + URL設計」の3点セット。最初にこの分け方を決めると後が楽
- Reactなら
next-intl(App Router向け)かi18next。メッセージはJSONで名前空間ごとに管理する - 複数形・日付・通貨は自前で組み立てず、ICU構文か
IntlAPI に任せる。語順や桁区切りは言語で変わる - 言語切替はパス文字列を置換せず、ロケール対応のルーターで「同じページのまま言語だけ替える」
- SEOは
hreflangと言語別URLが本体。これが無いと英語版が検索結果に出ない
i18nは「3つの仕事」に分けると迷わない
最初に頭を整理します。i18n(internationalization、国際化)を一語で言うと、アプリを後から何語にでも対応できる作りにすること。中身は3つに分かれます。
- メッセージの外出し — 画面に直接書いた文字列をJSONなどに追い出し、キーで呼ぶ
- 地域化(localization) — 複数形・日付・通貨・数値を、その言語の慣習に合わせて表示する
- URLとSEO設計 —
/en/pricingのような言語別URLを切り、検索エンジンに言語を伝える
翻訳作業は1番の一部にすぎません。僕が冒頭でやらかしたのは、1番だけ手を付けて2番を忘れたから。逆に2番3番を先に固めておくと、後からドイツ語でもインドネシア語でも、差分が小さく済みます。
ライブラリ選びはシンプルです。Next.jsのApp Routerならnext-intlが素直。Vite + React や Remix、あるいはフレームワーク非依存で使いたいならi18nextが定番です。この記事はnext-intlを軸に進めますが、考え方はi18nextでもそのまま通じます。
メッセージはJSONで、名前空間ごとに切る
文字列はページの責務ごとにまとめます。common に何でも放り込むと最初は楽ですが、後から「このキー、どこで使ってる?」が分からなくなって消せなくなります。ボタンやナビだけ common、ページ固有の文言は HomePage のように分けるのがコツです。
{
"common": {
"nav": { "docs": "ドキュメント", "pricing": "料金" }
},
"HomePage": {
"title": "チームの知識を多言語で届ける",
"lead": "{count}件の記事を、読者の言語で表示します。"
}
}
{
"common": {
"nav": { "docs": "Docs", "pricing": "Pricing" }
},
"HomePage": {
"title": "Deliver team knowledge in every language",
"lead": "Show {count} articles in the reader's language."
}
}
{count} のような波括弧が変数です。next-intl(内部はICU MessageFormat)はこの構文をそのまま解釈します。ここに「件」と書いてしまうと、英語側で「{count} articles件」みたいな崩れ方をするので、単位や助数詞も含めてメッセージ側に閉じ込めるのが鉄則です。
呼び出し側はキーで取り出します。Server Componentでもメタデータ生成でも使える getTranslations が扱いやすいです。
// src/app/[locale]/page.tsx
import { getTranslations, setRequestLocale } from 'next-intl/server';
export default async function HomePage({
params,
}: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: 'HomePage' });
return (
<main>
<h1>{t('title')}</h1>
{/* count=42 が {count} に差し込まれる */}
<p>{t('lead', { count: 42 })}</p>
</main>
);
}
言語切替とロケール検出をルーターに任せる
ここで一番やりがちな事故が、URLの言語部分を文字列置換で書き換えること。pathname.replace('/en', '/ja') のような実装は、/enquiry(問い合わせ)を /jaquiry に壊します。実際に僕も初回これで踏みました。
next-intlはロケール対応のルーターを用意しているので、それを使えば「今見ているページのまま、ロケールだけ替える」が安全にできます。対応言語とURLは1か所に集約します。
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['ja', 'en', 'de'],
defaultLocale: 'ja',
// ドイツ語だけ /preise のようにURLも訳したい場合はここで
pathnames: {
'/': '/',
'/pricing': { ja: '/pricing', en: '/pricing', de: '/preise' },
},
});
export type Locale = (typeof routing.locales)[number];
// src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, usePathname, useRouter } =
createNavigation(routing);
言語切替ボタンは、この useRouter の replace にロケールを渡すだけ。パス文字列は触りません。
// src/components/LanguageSwitcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation';
const languages = [
{ code: 'ja', label: '日本語' },
{ code: 'en', label: 'English' },
{ code: 'de', label: 'Deutsch' },
] as const;
export function LanguageSwitcher() {
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
return (
<div role="radiogroup" aria-label="表示言語">
{languages.map((lang) => (
<button
key={lang.code}
type="button"
role="radio"
aria-checked={locale === lang.code}
// パスは保ったまま、ロケールだけ差し替える
onClick={() => router.replace(pathname, { locale: lang.code })}
>
{lang.label}
</button>
))}
</div>
);
}
ロケール検出(初回アクセス時にどの言語を出すか)は、ブラウザの Accept-Language ヘッダーを見て決めるのが基本です。next-intlのミドルウェアはこれを自動でやってくれるので、/ に来た日本語ブラウザは /ja へ、英語ブラウザは /en へ案内されます。ただし一度ユーザーが切り替えたら、その選択をCookieで覚える設定にしておくと親切です。
複数形・日付・通貨は「組み立てない」
冒頭の¥0事故の本題です。地域化で自前の文字列連結をやると、ほぼ必ず壊れます。理由は単純で、語順も桁区切りも記号の位置も言語で違うから。
| やりがちな実装 | 何が壊れるか | 正しい道具 |
|---|---|---|
count + "件" を英語に流用 | 「3 items」にならず「3件」のまま | ICUの plural でメッセージ側に持たせる |
price + "円" / 桁区切り自作 | $1,000.00 の小数や , . の入れ替わりが崩れる | Intl.NumberFormat (currency) |
年月日 を文字列で組む | Mar 24, 2026 の語順にできない | Intl.DateTimeFormat |
複数形はメッセージのICU構文で書きます。=0 や one / other といったカテゴリは言語ごとに自動で選ばれます(英語は単複2種、ロシア語は3種以上、日本語は基本1種)。
{
"HomePage": {
"itemCount": "{count, plural, =0 {記事はまだありません} one {# 件の記事} other {# 件の記事}}"
}
}
通貨と日付は、ライブラリに頼らずブラウザ標準の Intl でも完結します。next-intlを使っていない環境でもそのまま動くので、まずこれだけ覚えておけば十分です。下のコードはコピペして node で実行できます。
// localize.ts — node localize.ts でそのまま動く
type Money = { amount: number; currency: string; locale: string };
function formatMoney({ amount, currency, locale }: Money): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount);
}
function formatDate(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
}
const now = new Date('2026-03-24T00:00:00Z');
// 同じ金額・同じ日付でも、ロケールで見た目が変わる
console.log(formatMoney({ amount: 3000, currency: 'JPY', locale: 'ja-JP' }));
// → ¥3,000
console.log(formatMoney({ amount: 20, currency: 'USD', locale: 'en-US' }));
// → $20.00
console.log(formatMoney({ amount: 20, currency: 'EUR', locale: 'de-DE' }));
// → 20,00 € (ドイツ語は小数点がカンマ、記号が後ろ)
console.log(formatDate(now, 'ja-JP')); // → 2026年3月24日
console.log(formatDate(now, 'en-US')); // → Mar 24, 2026
console.log(formatDate(now, 'de-DE')); // → 24. März 2026
ドイツ語で 20,00 € と小数点がカンマになり、記号が後ろに回るのが分かります。これを手で書こうとした瞬間に詰む、というのが地域化の核心です。
SEOはhreflangと言語別URLが本体
英語版を作ったのに検索結果に出ない、という相談をよく受けます。たいてい原因は、言語別URLが無いか、hreflang を出していないかのどちらかです。
Googleに「このページの日本語版・英語版・ドイツ語版はこれです」と教えるのが hreflang リンクです。Next.jsの App Router なら generateMetadata の alternates.languages で出せます。
// src/app/[locale]/layout.tsx の一部
export async function generateMetadata({
params,
}: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const base = 'https://example.com';
return {
alternates: {
// 自分自身の正規URL
canonical: `${base}/${locale}`,
// 各言語版の対応表。x-default は言語指定なしのフォールバック
languages: {
ja: `${base}/ja`,
en: `${base}/en`,
de: `${base}/de`,
'x-default': `${base}/en`,
},
},
};
}
ポイントは3つ。<html lang={locale}> を必ずロケールに合わせること、各言語版が相互に hreflang を指し合うこと(英語ページからも日本語版へのリンクが要る)、そして title や description といったメタデータも翻訳対象に入れること。本文だけ訳してメタデータを既定言語のまま残すと、検索結果のスニペットだけ日本語、という間抜けな状態になります。
このあたりの構造化データやサイト全体の設計は技術的SEOとAIO最適化のハブに土台をまとめてあるので、hreflangと合わせて読むと効きます。
翻訳漏れはCIで止める
10言語もやっていると、必ず「キーはあるけど1言語だけ訳し忘れ」が起きます。レビューで人間が気づくのは無理なので、機械で弾きます。基準言語と他言語のキー差分を出すスクリプトです。node scripts/check-translations.mjs で動きます。
// scripts/check-translations.mjs
import { readdir, readFile } from 'node:fs/promises';
const messagesDir = new URL('../src/messages/', import.meta.url);
const baseLocale = 'ja';
// ネストしたJSONをドット区切りのキー一覧に潰す
function flattenKeys(value, prefix = '') {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return [prefix];
}
return Object.entries(value).flatMap(([key, child]) =>
flattenKeys(child, prefix ? `${prefix}.${key}` : key),
);
}
async function readMessages(locale) {
return JSON.parse(await readFile(new URL(`${locale}.json`, messagesDir), 'utf8'));
}
const files = await readdir(messagesDir);
const locales = files.filter((f) => f.endsWith('.json')).map((f) => f.replace(/\.json$/, ''));
const baseKeys = new Set(flattenKeys(await readMessages(baseLocale)));
let hasError = false;
for (const locale of locales.filter((l) => l !== baseLocale)) {
const keys = new Set(flattenKeys(await readMessages(locale)));
const missing = [...baseKeys].filter((k) => !keys.has(k));
const extra = [...keys].filter((k) => !baseKeys.has(k));
if (missing.length || extra.length) {
hasError = true;
console.error(`\n${locale}.json のキーがズレています`);
if (missing.length) console.error('不足:', missing.join(', '));
if (extra.length) console.error('余分:', extra.join(', '));
}
}
if (hasError) process.exit(1);
console.log(`${locales.length}言語のキーが揃っています。`);
これを package.json の lint:i18n に登録してCIで回せば、「英語ページだけボタンが消える」事故は止まります。翻訳の自然さまでは保証できないので、そこは人間が見る。機械でわかる漏れは機械に、文化的な違和感は人間に、と役割を分けるのが現実的です。チーム運用なら、この流れをCLAUDE.mdは「何を書かないか」で決まるの要領でプロジェクトのルールに書いておくと、毎回ブレません。
よくある質問
Q. next-intlとi18next、どっちを選べばいい? Next.jsのApp Router中心なら next-intl が素直で、Server Componentやメタデータ生成と相性が良いです。Vite + React、Remix、React Native、あるいはバックエンドでも翻訳を使いたいなら i18next。エコシステム(言語検出プラグインやバックエンド連携)の広さは i18next が上です。
Q. 複数形は本当にライブラリ任せでいい?
はい。英語は2種でも、ロシア語やアラビア語は3〜6種のカテゴリがあります。これを手書きの if で再現するのは事故のもとです。ICUの plural 構文に書けば、言語ごとのカテゴリ選択は自動です。
Q. 言語はサブパス(/en)、サブドメイン(en.)、別ドメインのどれがいい?
多くのサイトはサブパス方式(/en/...)で十分です。実装が軽く、ドメインの評価も1つに集約できます。国ごとに法務やコンテンツが大きく違うなら別ドメインも選択肢ですが、運用コストは跳ね上がります。
Q. 機械翻訳をそのまま公開してもいい? 本文は機械翻訳ベースでも回りますが、CTA・価格・法務文言だけは人間が読んでください。「無料で始める」が直訳で不自然になると、コンバージョンに直結します。
Q. 既存サイトを多言語化するとき、URLは変えていい?
公開済みなら慎重に。既存URLを /ja/... に移すと検索流入や被リンクに影響します。301リダイレクトを必ず張り、hreflang と canonical を整えてから切り替えます。
実際に試した結果
このブログは10言語で出していますが、効いたのは賢い翻訳ツールではなく、地味な仕組みのほうでした。Intl.NumberFormat に通貨整形を寄せた日から、¥0事故は二度と起きていません。キー差分チェックをCIに入れてからは、訳し忘れのまま公開することがなくなりました。
順番はいつも同じです。①文字列を外出しする →②複数形・日付・通貨は Intl かICUに任せる →③言語別URLと hreflang を張る →④キー漏れをCIで止める。翻訳の質に悩む前に、この足場を先に作る。遠回りに見えて、多言語サイトを長く運用するにはこれがいちばん速い、というのが10言語やってみた今の実感です。
まずは手元のコンポーネント1つを、メッセージJSONに切り出すところから始めてみてください。チームで多言語サイトの更新フローごと整えたい場合は、研修・相談からリポジトリ構成を共有してもらえれば、踏みやすい地雷を先に潰せます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。