多言語サイトでは、利用者が日本語・英語などの表示言語を自分で選び、別ページへ移動しても選択状態が保たれる仕組みが必要です。JavaScriptでselectの変更を監視するだけなら簡単ですが、実務では翻訳先URLの対応、未対応言語のフォールバック、ブラウザ設定の初期判定、アクセシビリティ、検索エンジン向けのhreflangまで考える必要があります。
この記事では、言語ごとに別URLを持つ多言語サイトを前提に、安全な言語スイッチャーを実装します。ユーザーの明示選択を最優先し、ブラウザ言語は初回候補としてのみ利用します。
- 日本語版・英語版などは、検索可能な別URLとして用意します。
- selectのoptionには、サーバーが生成した現在ページの翻訳先URLを設定します。
- JavaScript側でも同一オリジン・許可言語を検証してから移動します。
- ユーザーが選んだ言語は
localStorageやログイン設定へ保存します。 navigator.languagesは初回候補の判定に使い、明示選択を上書きしません。- ページの主言語を
<html lang>で指定します。 - 言語別ページには相互の
hreflangと自己参照canonicalを設定します。 - URLパラメータやCookieは、サーバー側で許可言語の一覧と照合します。
選択値の取得・変更はselectタグの値とテキストを取得・設定する方法、保存APIの詳細はlocalStorageの使い方、言語別の日付・数値表示はIntl APIによる国際化フォーマットも参考になります。
多言語サイトのURL構成を決める
まず、各言語のコンテンツへ固有URLを割り当てます。代表的な構成は、サブディレクトリ、サブドメイン、国別ドメインです。
| 方式 | URL例 | 特徴 |
|---|---|---|
| サブディレクトリ | example.com/ja/products/ |
一つのサイトとして管理しやすい |
| サブドメイン | ja.example.com/products/ |
言語ごとにシステムを分けやすい |
| 国別ドメイン | example.jp/products/ |
国・地域を強く分ける場合に向く |
この記事では、同一オリジンのサブディレクトリ方式を使います。現在ページが/ja/products/widget/なら、英語版は/en/products/widget/、フランス語版は/fr/products/widget/です。
単にURL先頭の/ja/を/en/へ置換すると、翻訳が存在しないページや言語ごとにslugが異なるページで404になります。各ページの翻訳関係はCMSやサーバー側で管理し、実在するURLだけをスイッチャーへ出力します。
アクセシブルな言語選択selectを作る
selectには明示的なlabelを付けます。各optionのvalueには言語コードではなく、現在ページに対応する翻訳先URLを設定します。言語名はその言語自身の表記にすると見つけやすくなります。
<label for="language-select">表示言語</label>
<select id="language-select" aria-describedby="language-help">
<option value="/ja/products/widget/" data-locale="ja" lang="ja">
日本語
</option>
<option value="/en/products/widget/" data-locale="en" lang="en">
English
</option>
<option value="/fr/produits/widget/" data-locale="fr" lang="fr">
Français
</option>
</select>
<p id="language-help">
選択すると同じ内容の別言語ページへ移動します。
</p>
<script src="/assets/language-switcher.js" defer></script>
言語メニューのoptionへlangを付けると、支援技術が各言語名を適切に読み上げやすくなります。現在ページのoptionには、サーバー側でselectedを付けても構いません。次のJavaScriptでは<html lang>から現在言語を合わせます。
選択された言語URLへ安全に移動する
移動先を利用者入力から自由に受け取ると、外部サイトへ誘導するオープンリダイレクトにつながります。optionへ出力したURLでも、同一オリジンと許可言語を確認してから移動します。
const languageSelect = document.querySelector("#language-select");
const supportedLocales = new Set(["ja", "en", "fr"]);
const storageKey = "preferredLocale";
function safeStorageSet(key, value) {
try {
localStorage.setItem(key, value);
} catch {
// 保存が拒否されても言語切り替え自体は続行する
}
}
function getSelectedOption() {
return languageSelect.selectedOptions[0];
}
function navigateToSelectedLanguage() {
const option = getSelectedOption();
const locale = option?.dataset.locale;
if (!locale || !supportedLocales.has(locale)) {
return;
}
const destination = new URL(option.value, window.location.origin);
if (destination.origin !== window.location.origin) {
return;
}
safeStorageSet(storageKey, locale);
window.location.assign(destination.href);
}
languageSelect.addEventListener("change", navigateToSelectedLanguage);
URLは必ずhttps://で配信し、例示でもhttp://example.comへ移動するコードは避けます。window.location.assign()なら、通常のリンク移動と同じようにブラウザ履歴へ残ります。
現在ページの言語をselectへ反映する
ページの主言語は<html lang="ja">のように指定します。JavaScriptはこの値を読み、対応するoptionを選択状態にします。
function normalizeLocale(locale) {
return locale.toLowerCase().split("-")[0];
}
function syncCurrentLocale() {
const currentLocale = normalizeLocale(
document.documentElement.lang || "ja"
);
const currentOption = [...languageSelect.options].find(
(option) => option.dataset.locale === currentLocale
);
if (currentOption) {
languageSelect.value = currentOption.value;
}
}
syncCurrentLocale();
langは見た目の切り替え用ではありません。スクリーンリーダーの発音、ブラウザの翻訳支援、検索エンジンによる言語理解などに使われます。英語ページならHTML自体を<html lang="en">として出力してください。
選択した言語をlocalStorageへ保存する
localStorageへ言語コードを保存すると、同一オリジン内で再訪問しても選択を参照できます。ただし、ブラウザ設定で保存を拒否される場合があるため、読み書きは例外処理で囲みます。
const storageKey = "preferredLocale";
function safeStorageGet(key) {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function getStoredLocale() {
const locale = safeStorageGet(storageKey);
return supportedLocales.has(locale) ? locale : null;
}
const storedLocale = getStoredLocale();
console.log(storedLocale);
サブドメイン方式では、ja.example.comとen.example.comのlocalStorageは共有されません。複数サブドメインや複数端末で設定を共有したい場合は、ログインユーザーのプロフィールや適切に設定したCookieをサーバー側で利用します。
navigator.languagesから初期候補を判定する
navigator.languagesは、ブラウザで設定された優先言語を順番に返します。en-USはenへ正規化し、サイトが対応する最初の言語を候補にします。
function detectSupportedLocale() {
const browserLocales = navigator.languages?.length
? navigator.languages
: [navigator.language];
for (const locale of browserLocales) {
const normalized = normalizeLocale(locale);
if (supportedLocales.has(normalized)) {
return normalized;
}
}
return "ja";
}
const detectedLocale = detectSupportedLocale();
console.log(detectedLocale);
ブラウザ言語は利用者本人の明示選択ではなく、完全な一覧が取得できるとも限りません。次の優先順位が扱いやすい設計です。
- ユーザーが明示的に選択した保存済み言語
- ログインユーザーのプロフィール設定
- 現在アクセスしているURLの言語
- 初回アクセス時の
navigator.languagesまたはAccept-Language - サイトの既定言語
初回アクセスで自動リダイレクトしすぎない
ブラウザ言語だけで毎回強制転送すると、別言語を読みたい利用者が元へ戻され続けます。また、検索エンジンや共有URLの挙動も分かりにくくなります。初回は候補を表示し、利用者が選択してから保存する方法が安全です。
<aside
id="language-suggestion"
aria-live="polite"
hidden
>
<p>
<span id="language-suggestion-text"></span>
<a id="language-suggestion-link" href="#"></a>
</p>
<button type="button" id="dismiss-language-suggestion">
この言語のまま表示
</button>
</aside>
const storedLocale = getStoredLocale();
const currentLocale = normalizeLocale(document.documentElement.lang || "ja");
const detectedLocale = detectSupportedLocale();
const suggestion = document.querySelector("#language-suggestion");
const suggestionText = document.querySelector("#language-suggestion-text");
const suggestionLink = document.querySelector("#language-suggestion-link");
const detectedOption = [...languageSelect.options].find(
(option) => option.dataset.locale === detectedLocale
);
if (
!storedLocale &&
detectedLocale !== currentLocale &&
detectedOption
) {
const destination = new URL(
detectedOption.value,
window.location.origin
);
if (destination.origin === window.location.origin) {
suggestionText.textContent =
`${detectedOption.textContent.trim()}版があります。`;
suggestionLink.textContent =
`${detectedOption.textContent.trim()}で表示`;
suggestionLink.href = destination.href;
suggestionLink.hreflang = detectedLocale;
suggestionLink.lang = detectedLocale;
suggestion.hidden = false;
}
}
document
.querySelector("#dismiss-language-suggestion")
.addEventListener("click", () => {
suggestion.hidden = true;
safeStorageSet(storageKey, currentLocale);
});
候補リンクは検出された言語に対応するoptionから生成するため、フランス語を検出した利用者へ英語版を案内する不整合を防げます。自動転送を採用する場合でも、ユーザーが選んだ言語を最優先にし、戻る手段が常に見える言語スイッチャーを残してください。
保存済み言語を入口ページで適用する
保存済み言語へ自動で移動させる場合は、/language/のような言語未確定の入口ページだけに限定します。日本語版や英語版の各ページで実行すると、共有された別言語URLを開いても保存言語へ戻され、利用者が言語を切り替えられなくなります。
<select id="language-select" data-language-entry="true" aria-label="表示言語" > <option value="/ja/" data-locale="ja" lang="ja">日本語</option> <option value="/en/" data-locale="en" lang="en">English</option> <option value="/fr/" data-locale="fr" lang="fr">Français</option> </select>
function getSafeDestination(locale) {
const option = [...languageSelect.options].find(
(item) => item.dataset.locale === locale
);
if (!option) {
return null;
}
const destination = new URL(
option.value,
window.location.origin
);
return destination.origin === window.location.origin
? destination
: null;
}
function applyStoredLocaleOnEntryPage() {
if (languageSelect.dataset.languageEntry !== "true") {
return;
}
const storedLocale = getStoredLocale();
if (!storedLocale) {
return;
}
const destination = getSafeDestination(storedLocale);
if (!destination) {
return;
}
window.location.replace(destination.href);
}
applyStoredLocaleOnEntryPage();
location.replace()を使うと、ブラウザの戻る操作で入口ページへ戻って再転送されるループを避けやすくなります。保存設定がない初回利用者には入口ページを表示し、言語を明示的に選んでもらいます。
完成版の言語スイッチャー
次の完成版は、現在言語の同期、許可言語の検証、選択保存、同一オリジン確認、入口ページでの保存言語適用をまとめています。翻訳先URLは各ページでサーバーやCMSが生成する前提です。
const languageSelect = document.querySelector("#language-select");
const supportedLocales = new Set(["ja", "en", "fr"]);
const storageKey = "preferredLocale";
function normalizeLocale(locale) {
return String(locale || "").toLowerCase().split("-")[0];
}
function safeStorageGet(key) {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function safeStorageSet(key, value) {
try {
localStorage.setItem(key, value);
} catch {
// ストレージが使えなくても画面遷移は行う
}
}
function getStoredLocale() {
const locale = normalizeLocale(safeStorageGet(storageKey));
return supportedLocales.has(locale) ? locale : null;
}
function detectSupportedLocale() {
const browserLocales = navigator.languages?.length
? navigator.languages
: [navigator.language];
for (const locale of browserLocales) {
const normalized = normalizeLocale(locale);
if (supportedLocales.has(normalized)) {
return normalized;
}
}
return "ja";
}
function findOptionByLocale(locale) {
return [...languageSelect.options].find(
(option) => option.dataset.locale === locale
);
}
function getSafeDestination(locale) {
const option = findOptionByLocale(locale);
if (!option) {
return null;
}
const destination = new URL(
option.value,
window.location.origin
);
return destination.origin === window.location.origin
? destination
: null;
}
function syncCurrentLocale() {
const currentLocale = normalizeLocale(
document.documentElement.lang || "ja"
);
const option = findOptionByLocale(currentLocale);
if (option) {
languageSelect.value = option.value;
}
}
function changeLanguage() {
const option = languageSelect.selectedOptions[0];
const locale = normalizeLocale(option?.dataset.locale);
if (!supportedLocales.has(locale)) {
return;
}
const destination = getSafeDestination(locale);
if (!destination) {
return;
}
safeStorageSet(storageKey, locale);
window.location.assign(destination.href);
}
function applyStoredLocaleOnEntryPage() {
if (languageSelect.dataset.languageEntry !== "true") {
return;
}
const storedLocale = getStoredLocale();
if (!storedLocale) {
return;
}
const destination = getSafeDestination(storedLocale);
if (destination) {
window.location.replace(destination.href);
}
}
languageSelect.addEventListener("change", changeLanguage);
syncCurrentLocale();
applyStoredLocaleOnEntryPage();
// 初回案内などで利用できる値
const preferredLocale = getStoredLocale() ?? detectSupportedLocale();
hreflangとcanonicalを設定する
JavaScriptの切り替えメニューだけでは、検索エンジンへ翻訳ページの関係を十分に伝えられません。各言語ページの<head>へ、すべての言語版を相互に示すhreflangを出力します。
<link rel="canonical" href="https://example.com/ja/products/widget/" > <link rel="alternate" hreflang="ja" href="https://example.com/ja/products/widget/" > <link rel="alternate" hreflang="en" href="https://example.com/en/products/widget/" > <link rel="alternate" hreflang="fr" href="https://example.com/fr/produits/widget/" > <link rel="alternate" hreflang="x-default" href="https://example.com/language-selector/products/widget/" >
- 各言語ページから、他言語版を含む同じ一式を出力する
- 日本語ページのcanonicalは日本語URL、英語ページは英語URLへ向ける
- 言語コードはHTMLと同様にBCP 47形式を使う
- 地域差がある場合は
en-US、en-GBのように指定する x-defaultは言語選択ページや既定ページへ設定する
翻訳ページを一つのcanonical URLへまとめると、各言語ページが検索結果に出にくくなります。内容が翻訳されているなら、言語版ごとに自己参照canonicalを設定します。
ページ内の文言だけをJavaScriptで差し替える方法
URLを変えずに辞書オブジェクトから文言を差し替える方法は、管理画面や小規模ツールには使えます。しかし公開サイトでは、検索エンジンが言語別URLを発見しにくく、共有・キャッシュ・サーバーレンダリングも複雑になります。
const messages = {
ja: {
heading: "製品情報",
buy: "購入する"
},
en: {
heading: "Product information",
buy: "Buy now"
}
};
function renderMessages(locale) {
const dictionary = messages[locale] ?? messages.ja;
document.querySelector("[data-i18n='heading']").textContent =
dictionary.heading;
document.querySelector("[data-i18n='buy']").textContent =
dictionary.buy;
document.documentElement.lang = locale;
}
この方式でも<html lang>を更新し、翻訳漏れ、HTMLを含む文言の扱い、複数形、日付・通貨、右から左へ書く言語を考慮する必要があります。公開コンテンツのSEOを重視するなら、言語別URLとサーバーレンダリングを推奨します。
サーバー側で許可言語を検証する
クエリパラメータやCookieから言語を受け取る場合、値をそのままテンプレート名やファイルパスへ連結してはいけません。許可リストと照合し、対応言語だけを採用します。
<?php
$supportedLocales = ['ja', 'en', 'fr'];
$defaultLocale = 'ja';
function normalizeLocale($locale): string
{
if (!is_string($locale)) {
return '';
}
$locale = strtolower(str_replace('_', '-', $locale));
return explode('-', $locale)[0];
}
$requested = normalizeLocale($_GET['lang'] ?? '');
$stored = normalizeLocale($_COOKIE['preferred_locale'] ?? '');
if (in_array($requested, $supportedLocales, true)) {
$locale = $requested;
} elseif (in_array($stored, $supportedLocales, true)) {
$locale = $stored;
} else {
$locale = $defaultLocale;
}
setcookie('preferred_locale', $locale, [
'expires' => time() + 60 * 60 * 24 * 365,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
// 許可済み値だけをテンプレート選択へ使用する
$template = __DIR__ . "/templates/{$locale}/page.php";
require $template;
Accept-Languageを使う場合も、最初の候補をそのまま信用せず、正規化して対応言語と照合します。ログイン済みユーザーなら、ブラウザやCookieよりプロフィール設定を優先すると端末間で選択を共有できます。
言語切り替えでよくある失敗
毎回ブラウザ言語へ戻される
自動判定がユーザーの明示選択より優先されています。保存済み設定を先に確認し、自動判定は初回だけにします。
別言語に切り替えるとトップページへ戻る
言語コードだけでURLを組み立てています。現在ページに対応する翻訳先URLをCMSで管理し、optionへ実在URLを出力します。
検索結果に特定の言語しか出ない
言語別URL、相互hreflang、自己参照canonical、サイトマップ、内部リンクを確認します。JavaScriptで文言だけを差し替える構成では、言語別ページとして認識されにくくなります。
localStorageへ保存できない
プライバシー設定や保存ポリシーによって拒否される場合があります。例外を捕捉し、保存できなくても言語選択とページ移動は動くようにします。
よくある質問
まとめ
JavaScriptで言語切り替えを実装する場合は、selectのchangeイベントだけでなく、現在ページに対応する翻訳先URL、許可言語の検証、ユーザー選択の保存まで設計します。ブラウザ言語は初回候補として使い、ユーザーの明示選択を上書きしないことが重要です。
公開サイトでは、言語ごとの固有URL、正しい<html lang>、相互hreflang、自己参照canonicalを揃えます。JavaScriptは切り替え操作を支援し、コンテンツの言語判定とURL生成はサーバーやCMSが責任を持つ構成が安定します。

