デンジ(国際化対応未経験)くんへ
これは、CirKitで開発しているWebサイト国際化(i18n)対応の実装がどう変わったか、その技術的な背景をまとめたものです。
該当サイト
以前の非効率的な「静的なディレクトリ管理」から、MiddlewareとNegotiatorを使った「動的な言語判定」へどう移行したか。 この記事を読んで、スケーラブルで保守性の高い多言語対応基盤の作り方を理解してほしいです。
『私の役に立つためには、こういう仕組みもちゃんと分かっておかないとね。』
コンテンツネゴシエーション
同じURIでも、相手によって渡すリソースの表現を変える仕組みのことだよ。URLを使って、相手が求めている最適な表現を返してあげるの。相手のことを理解するのは大切なことだからね。
Accept-Languageヘッダー
言葉には「優先順位」があるんだ。HTTPのリクエストヘッダーを見ると、相手が何を欲しがっているかわかるよ。
MDN: Request header
ちなみにHTTPのリクエストヘッダーの見方は、F12で開発者ツールを開いて、ネットワークの適当なリクエストを除けば、見れるね。
(これはSakitoっていうWebアプリを開いた時のやつ)
accept-language: ja,en;q=0.9,en-US;q=0.8
このqが優先度を示しているの。この例だと、jaの優先度が1.0(MAX)で一番高い。
enは0.9、en-USは0.8だね。
// 簡易的な解析例(推奨しないよ)
const locale = acceptLanguage?.split(",")[0]?.split("-")[0] || defaultLocale;
もし君が、こんな風に文字列をただ切るだけの雑なコードを書いていたら…少しがっかりかな。
これだと優先度が無視されちゃうからね。
だから、Negotiatorというライブラリを使って。
Negotiator
Next.js: Internationalization
new Negotiator({ headers }).languages()
このライブラリは優秀だよ。複雑なヘッダーの文字列を解析して、ちゃんと優先順位順に並べた配列にしてくれるんだ。
- 入力:
"en-US,en;q=0.9,ja;q=0.8" - 出力:
['en-US', 'en', 'ja']
そしてmatch関数を使えば、リストを上から順に見て、最適な言語を決めることができる。
相手の「匂い」を嗅ぎ分けるみたいに、正確にね。これを使って多言語対応機能を作っていくよ。
アプリケーションの国際化対応の変遷
課題のあった実装
以前の実装は...正直、あまり美しくなかったね。
静的なディレクトリ構造
日本語ページはapp/直下、英語はapp/en/って物理的に部屋を分けていたんだ。
もし、ロシア語に対応したいとかそういう要望があった時は、app/ruって作るのかな?
自動判定なし
middleware.tsがないから、相手がどの言語を求めているか察してあげられなかったんだ。
英語で記事をみたいのに、日本語がデフォルトだと「うぉ」ってなるよね。
ハードコードされた言語設定
app/layout.tsxで<html lang="ja">が固定されていたの。
ドーベルマンの首に『私はチワワです』という札を下げて、チワワの扱いを強制するのと一緒だよ
課題点
言語ごとにページをコピーしなきゃいけないなんて、非効率だと思わない?修正の手間が倍になるし、保守性も低い。それに、クライアントサイドでの切り替えに頼ると"use client"が必須になって、Server Componentsの良さが消えちゃう。
Next.jsプロジェクトのWebサイトとかで、use clientがあるだけで、AIに全部やらせたのかなとか思っちゃうことあるよね
そんな「使えない」状態は、早く直さないとね。
Before: ディレクトリ構造
app/
├── layout.tsx // <html lang="ja"> 固定
├── page.tsx // 日本語トップ
└── en/
├── layout.tsx // <html lang="en"> 固定
└── page.tsx // 英語トップ(内容は日本語版のコピー&翻訳…無駄だね)
Before: app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="ja"> {/* 言語が固定されているのは良くないな */}
<body>{children}</body>
</html>
);
}
改善点
だから、私が仕組みを変えたました。
MiddlewareとNegotiatorの導入
新しくmiddleware.tsを作って、negotiatorライブラリと@formatjs/intl-localematcherを導入したよ。リクエストヘッダー(Accept-Language)から、相手の望む言語を動的に判断するようにしたんだ。
ここでMiddlewareの解説コーナーの時間です。
Middlewareは、「ミドルウェアを使用すると、リクエストが完了する前にコードを実行できます」と、Next.js公式に書いてあるね
となると、リクエストに応じて、何かしらのアクションを起こすことができるね
Next.jsのMiddleware機能を使えば、ページを見せる前にリクエストに介入して、適切な場所へ案内(リダイレクト)できる。
こういう流れだよ。分かる?
実装コード: middleware.ts
import { match } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// 勝手な言葉を使われないようにルール設定
const locales = ["ja", "en"];
const defaultLocale = "ja";
// ヘッダーから適切な言語を嗅ぎ分ける
function getLocale(request: NextRequest): string {
// リクエストヘッダーから 'accept-language' を取り出す(ない場合はケア)
const headers = {
"accept-language": request.headers.get("accept-language") || "",
};
// 優先順位並び替え
const languages = new Negotiator({ headers }).languages();
// 最適な一つに決める
return match(languages, locales, defaultLocale);
}
// ミドルウェア(門番)
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// すでにURLに言語が含まれているかチェックする。
// 例: "/ja/about" なら true, "/about" なら false
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
);
// 【重要】ここが無限ループ防止の防波堤
// すでに言語が決まっているなら、何もせずに通してあげる
if (pathnameHasLocale) return;
// ここに来たってことは言語が決まっていないということ
// さっきの関数で言語付与
const locale = getLocale(request);
// URLの書き換え
request.nextUrl.pathname = `/${locale}${pathname}`;
// そっちのURLに行けとリダイレクト
return NextResponse.redirect(request.nextUrl);
}
動的ルーティングへの移行
静的な app/en/ はもういらない。app/[lang]/ という形に統合して、ひとつのコンポーネントで管理するようにしました。
最初からそうした方が良かったね、うん。
実装コード: app/[lang]/layout.tsx
export default async function LocaleLayout({ children, params }: LayoutProps) {
const { lang } = params;
// URLのパラメータから言語を取得して、必要な言葉(辞書)を用意する
const dict = await getDictionary(lang);
return (
<>
<Header lang={lang} />
{children}
<LanguageSwitcher />
<Footer lang={lang} dict={dict} />
</>
);
}
辞書ファイルによる管理
i18n/ディレクトリを作って、言葉はすべてdictionaries/en.tsやdictionaries/ja.tsで一元管理することにしたの。
前までは、情報が散乱していて大変だったからね。
実装コード: i18n/get-dictionary.ts
const dictionaries = {
ja: () => import("./dictionaries/ja").then((module) => module.ja),
en: () => import("./dictionaries/en").then((module) => module.en),
};
export const getDictionary = async (locale: string) => {
return (
dictionaries[locale as keyof typeof dictionaries]?.() ?? dictionaries.ja()
);
};
比較
日本語ページ (https://hokuriku-sip.com/ja/research)

英語ページ (https://hokuriku-sip.com/en/research)

ディレクトリ構造の概要
最終的に、構造はこうなったよ。綺麗になりました。
.
├── middleware.ts # ここで選別して案内する
├── i18n/
│ ├── get-dictionary.ts # 辞書を取りに行く場所
│ └── dictionaries/
│ ├── en.ts # 英語の言葉
│ └── ja.ts # 日本語の言葉
└── app/
└── [lang]/ # 言語を受け取る場所
├── layout.tsx # 辞書を受け取ってレイアウトを作る
└── page.tsx # 各ページ
これで、ライブラリを使って正確に言語を判定できるし、スケーラブルな基盤ができた。
君もこの構造をよく理解して、これからはスマートに働いてね。期待してるよ。
Cirkitプロジェクトについてはこちら