はじめに
Next.js App Routerで開発しているWebサービス「EO PLACE」を、日本語だけの構成から日本語・英語対応へ拡張しました。
多言語化というと辞書の用意が中心に見えますが、実際には次の設計を同時に揃える必要がありました。
- URLを日本語版と英語版でどう分けるか
- Server Componentsが表示言語をどう判定するか
- 言語切替後もパス、検索条件、ハッシュをどう維持するか
-
canonical、hreflang、OGP、構造化データをどう出し分けるか - ユーザー投稿のどこまでを翻訳するか
- 海外リージョンの営業時間をどのタイムゾーンで判定するか
この記事では、実際に採用した構成と、実装中に見つかった落とし穴をまとめます。コード例のドメイン、Cookie名、ヘッダー名などは説明用に一般化しています。
前提と方針
対象はNext.js App Routerを使ったSSR中心のサービスです。既存の日本語URLを変えず、英語版だけを/en配下へ追加しました。
/explore 日本語版
/en/explore 英語版
/shops/example 日本語版の詳細
/en/shops/example 英語版の詳細
日本語版も/jaへ移す設計にしなかったのは、すでに存在するURLを維持したかったためです。また、初期表示をブラウザ言語で自動転送するのではなく、利用者が明示的に選んだ言語を保存する方針にしました。
翻訳対象にも境界を設けました。
| データ | 英語表示での扱い |
|---|---|
| ナビゲーション、ボタン、入力項目 | 翻訳する |
| システムが用意したカテゴリ、タグ | 翻訳する |
| 住宅街などの固定マスタ | 言語別ラベルで表示する |
| 店舗名、紹介文、プロフィール文 | 自動翻訳しない |
| 利用者が自由入力したタグ | 自動翻訳しない |
ユーザー投稿を機械的に翻訳すると、固有名詞やロールプレイ上の表現を壊す可能性があります。そのため、UIとシステム管理の値だけを翻訳し、投稿内容は原文を尊重しました。
辞書とLocale型を小さく始める
対応言語が日本語と英語の2言語だったため、まずは外部のi18nライブラリを導入せず、TypeScriptの共通辞書から始めました。
export const LOCALES = ["ja", "en"] as const;
export type Locale = (typeof LOCALES)[number];
export function isLocale(value: unknown): value is Locale {
return typeof value === "string"
&& (LOCALES as readonly string[]).includes(value);
}
export const messages = {
ja: {
nav: { explore: "見つける", login: "ログイン" },
shop: { address: "住所", copy: "住所をコピー" }
},
en: {
nav: { explore: "Explore", login: "Log in" },
shop: { address: "Address", copy: "Copy address" }
}
} as const;
コンポーネントはlocaleを受け取り、同じ構造の辞書を参照します。
export function Navigation({ locale }: { locale: Locale }) {
const text = messages[locale].nav;
return <a href={localizedPath(locale, "/explore")}>{text.explore}</a>;
}
as constで辞書の構造を固定すると、一方の言語だけキーが欠けた箇所を見つけやすくなります。一方、文章量の多い画面まで単一ファイルへ集めると見通しが悪くなるため、規模が増えたら機能単位の辞書へ分割する前提です。
/enを内部ルートへrewriteする
英語版のためにページ一式を複製すると、機能追加のたびに日本語版と英語版の両方を直すことになります。そこで、/enを既存ルートへ内部的にrewriteし、リクエストヘッダーでLocaleを渡しました。
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname !== "/en" && !pathname.startsWith("/en/")) {
return NextResponse.next();
}
const rewritten = request.nextUrl.clone();
rewritten.pathname = pathname === "/en" ? "/" : pathname.slice(3);
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-app-locale", "en");
return NextResponse.rewrite(rewritten, {
request: { headers: requestHeaders }
});
}
export const config = {
matcher: ["/en", "/en/:path*"]
};
ブラウザ上のURLは/en/exploreのままですが、Next.jsは既存の/exploreを実行します。ページを複製せず、Server Componentsとデータ取得処理も共有できます。
Localeの決定では、/enへのアクセスを確実に英語として扱うため、rewrite時のヘッダーをCookieより優先しました。
import { cookies, headers } from "next/headers";
export async function getLocale(): Promise<Locale> {
const requestLocale = (await headers()).get("x-app-locale");
if (isLocale(requestLocale)) return requestLocale;
const savedLocale = (await cookies()).get("app_locale")?.value;
return isLocale(savedLocale) ? savedLocale : "ja";
}
日本語側では保存済みCookieを参照し、未選択なら日本語を既定値にします。言語切替時はCookieの更新とURLの移動をセットで行うため、表示言語とURLを揃えられます。
言語設定はサーバー側でCookieへ保存する
言語切替では、選択値をAPIへ送り、検証後にCookieへ保存しました。
export async function POST(request: NextRequest) {
if (!hasTrustedOrigin(request)) {
return Response.json({ error: "Invalid origin" }, { status: 403 });
}
const body = await request.json().catch(() => undefined) as
| { locale?: unknown }
| undefined;
if (!isLocale(body?.locale)) {
return Response.json({ error: "Invalid locale" }, { status: 400 });
}
const response = NextResponse.json({ data: { locale: body.locale } });
response.cookies.set("app_locale", body.locale, {
path: "/",
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: true
});
return response;
}
CookieはServer Componentsから参照でき、httpOnlyにしてクライアントJavaScriptから直接変更させない構成にできます。APIではLocaleの許可リストだけでなく、想定したOriginからのリクエストかも確認しています。
パスだけでなく検索条件とハッシュも維持する
言語を切り替えたとき、検索中のリージョンやカテゴリが消えると使い勝手が悪くなります。現在のpathnameだけでなく、searchとhashも結合して変換します。
export function localizedPath(locale: Locale, href: string) {
if (!href.startsWith("/") || href.startsWith("/api/")) return href;
if (locale === "en") {
if (href === "/") return "/en";
return href === "/en" || href.startsWith("/en/")
? href
: `/en${href}`;
}
if (href === "/en") return "/";
return href.startsWith("/en/") ? href.slice(3) || "/" : href;
}
async function changeLocale(nextLocale: Locale) {
const response = await fetch("/api/locale", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locale: nextLocale })
});
if (response.ok) {
const current =
`${window.location.pathname}${window.location.search}${window.location.hash}`;
window.location.assign(localizedPath(nextLocale, current));
}
}
ここでは二重に/enを付けないことも重要です。実装直後、英語URLからの切替処理に問題があり、/enの有無とクエリ文字列を含む往復をE2Eテストへ追加しました。
<html lang>だけでは足りないSEO対応
多言語ページでは、表示文言だけでなく検索エンジンへ言語別URLの対応関係を伝える必要があります。
各公開ページで、現在の言語に応じたcanonicalと、日本語・英語の対応URLを設定しました。
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale();
const en = locale === "en";
return {
title: en ? "Find venues" : "お店を探す",
description: en
? "Find player-run venues by region and category."
: "リージョンやカテゴリからプレイヤー店舗を探せます。",
alternates: {
canonical: localizedPath(locale, "/explore"),
languages: {
ja: "/explore",
en: "/en/explore"
}
},
openGraph: {
locale: en ? "en_US" : "ja_JP"
}
};
}
ルートレイアウトでは、<html lang>、OGP、WebSite構造化データ、Webアプリmanifestも表示言語へ合わせました。
検索対象にする必要がないログイン画面や管理画面には、言語別ページが存在してもnoindexを設定します。サイトマップには公開ページだけを掲載し、日本語版と英語版の対応も出力します。
多言語SEOで確認した項目は次のとおりです。
<html lang="ja|en">- 言語ごとの
titleとdescription - 自己参照canonical
- 日本語版・英語版の
hreflang og:locale- 構造化データの
inLanguage - manifestの
langとstart_url - 非公開系ページの
noindex - サイトマップの言語別URL
固定値は表示名ではなくコードで保存する
多言語化前は、住宅街などの選択肢を日本語名のまま保存しても動作します。しかし、英語表示を追加すると日本語の値がデータモデルへ入り込んでいることが問題になります。
そこで、DBとAPIでは言語に依存しないコードを保存し、画面表示時だけラベルへ変換しました。
const HOUSING_DISTRICT_LABELS = {
mist: { ja: "ミスト・ヴィレッジ", en: "Mist" },
lavender_beds: { ja: "ラベンダーベッド", en: "The Lavender Beds" }
} as const;
export function getHousingDistrictLabel(
district: keyof typeof HOUSING_DISTRICT_LABELS,
locale: Locale
) {
return HOUSING_DISTRICT_LABELS[district][locale];
}
既存データはマイグレーションで日本語名から固定コードへ移しました。API入力ではリージョン、Data Center、Worldの組み合わせもサーバー側で検証します。
この形にしておくと、3言語目を追加してもDBの値は変わりません。表示名の変更もデータ移行なしで行えます。
多言語化と同時にタイムゾーンも見直す
海外向けに英語UIを用意しても、「営業中」の判定が常に日本時間では正しくありません。店舗ごとにIANAタイムゾーンを保存し、アプリとPostgreSQLの両方で同じ基準を使うようにしました。
create function shop_is_open_now(
days smallint[],
opening time,
closing time,
shop_time_zone text,
checked_at timestamptz default now()
) returns boolean
language sql
stable
as $$
with local_now as (
select
extract(isodow from checked_at at time zone shop_time_zone)::smallint
as day_number,
(checked_at at time zone shop_time_zone)::time as local_time
)
select exists (
select 1 from local_now
where day_number = any(days)
and local_time >= opening
and local_time < closing
);
$$;
実際の営業判定では、日付をまたぐ営業時間や24時間営業も扱います。大切なのは、日時を単純に翻訳対象と考えず、利用者や店舗の地域に依存するドメインロジックとして扱うことでした。
既存店舗には従来のタイムゾーンを設定してからNOT NULL制約を付け、新規登録ではリージョンに許可したタイムゾーンだけを受け付けます。
テストで守った境界
文字列の翻訳だけでなく、URL、SEO、地域データをテスト対象にしました。
it("英語URLを重複なく生成する", () => {
expect(localizedPath("en", "/")).toBe("/en");
expect(localizedPath("en", "/explore?region=NA"))
.toBe("/en/explore?region=NA");
expect(localizedPath("ja", "/en/shops/example"))
.toBe("/shops/example");
});
it("固定タグだけを翻訳する", () => {
expect(localizeTag("初心者歓迎", "en"))
.toBe("Beginners welcome");
expect(localizeTag("Custom tag", "en"))
.toBe("Custom tag");
});
E2Eテストでは、次をブラウザ上で確認しました。
-
/en配下が英語でSSRされる - 英語URLと日本語URLを検索条件付きで往復できる
-
lang、canonical、hreflang、og:localeが一致する - 英語版manifestの
langとstart_urlが正しい - 英語版から遷移してもリンクが日本語URLへ戻らない
地域対応では、Data CenterとWorldの不正な組み合わせを拒否すること、店舗のタイムゾーンによって同じ瞬間の営業判定が変わることもユニットテストに含めました。
実装して分かったこと
1. LocaleはClient Componentsだけで持たない
クライアント側のstateだけで切り替えると、初回SSR、メタデータ、構造化データとの不一致が起きます。URL、リクエストヘッダー、CookieからServer Componentsが同じLocaleを取得できる構成が必要でした。
2. URL変換関数を共通化する
リンクごとに/enを手書きすると、付け忘れと二重付与が発生します。通常リンク、ログイン後の戻り先、メール内リンク、言語切替を同じ関数へ寄せると追いやすくなります。
3. 翻訳しないデータを先に決める
翻訳対象を増やすことより、ユーザー投稿や固有名詞を勝手に変えない境界のほうが重要でした。システム定義タグだけを変換し、未知の値はそのまま返す実装にすると、自由入力を壊しません。
4. 地域対応はUI翻訳だけでは終わらない
海外リージョンを扱うサービスでは、Worldの所属関係、住所表記、曜日、時刻、日付変更をまたぐ営業時間まで見直す必要があります。Localeとタイムゾーンは別の概念として保存・検証するべきです。
5. SEO情報も表示の一部としてテストする
画面が英語でも、canonicalが日本語URLを指していたり、hreflangが欠けていたりすると多言語ページとして不完全です。DOMに出力されたメタ情報をE2Eテストで確認すると、リファクタリング時の後退を防げます。
まとめ
Next.js App Routerで既存サイトを多言語化する際は、辞書だけでなく次の層を一つの設計として扱う必要がありました。
-
/enによる言語別URL - rewriteヘッダーとCookieによるSSRのLocale判定
- 検索条件を維持する共通URL変換
- canonical、
hreflang、OGP、構造化データ、サイトマップ - 固定コードと表示ラベルの分離
- ユーザー投稿を自動翻訳しない境界
- 店舗単位のIANAタイムゾーン
- URL・SEO・地域ロジックを含む自動テスト
多言語化を表示文言の置換として始めると、後からURLやデータモデルの修正が大きくなります。最初に「Localeはどこから決まり、どのデータを変えず、検索エンジンへ何を伝えるか」を決めることが、既存サービスへ安全に追加する近道でした。