🌐

Nextjsのi18nで、あらゆる言語を徹底的にリダイレクトする

7 min read

日本のウェブサイトでこんな実装をする機会は、ほぼないと思います。.jpがあるんですから、パスでi18nルーティングすること自体が希少です。

この記事では、何かしらの理由で全ページに言語コードの接頭辞を付ける場合の対処法を説明します。

ブラウザの設定 Accept-Language ページ 結果 備考
日本語 ja / 302 Found→/ja
日本語 ja /en 200 OK→/en (転送されない)
英語(米国) en-US,en;q=0.9 / 302 Found→/en
英語(米国) en-US,en;q=0.9 /ja 200 OK→/ja (転送されない)
韓国語 ko / 302 Found→/ja
韓国語 ko /ja 200 OK→/ja (転送されない)
韓国語 ko /en 200 OK→/en (転送されない)

例えば 「日本語と英語を定義し、日本語がデフォルト」 の場合こうなります。/ja/enが付いていれば転送しません。

何をするか把握できた人は実装まで飛ばしてください。

ページ内容のローカライズについては扱いません(next-i18nextを併用してください)。また、hreflang属性については扱いません。

いろいろなi18nルーティングの例

i18nルーティングには、以下の3パターンがあります。

  1. ドメインルーティング
  2. Prefix except edfault (デフォルト以外は接頭辞)
  3. Prefix (全部接頭辞)
  4. Prefix and default (デフォルトも接頭辞も)

1〜3はNuxtのi18nモジュールの説明から引用しました。

0. ドメインルーティング

例えば、「.jp」と「.us」を使えばいいのです。「.com」をアメリカに割り振る企業も多いですが、その場合/usがついていたりします。

ぜんぜん違う話になってきますから、この記事ではドメインルーティングについて扱いません。

1. prefix_except_default (デフォルト以外は接頭辞)

英語中心のソフトウェアのウェブサイトに最適です。多国籍サイトを立ち上げるなら最も主流の方法でしょう。

言語コードも国コードも

例としてDocusaurusのウェブサイトを挙げます。

言語だけのルーティングも許容されています。

国コードだけ

例としてAppleのウェブサイトを挙げます。

フィジカルな商売をする多国籍企業は、「国コードだけ」が最適解でしょう。

2. prefix (全部接頭辞)

トップ以外リダイレクトはしない

例としてGitHubのドキュメントを挙げます。

トップページ以外ではリダイレクトはせず、「This article is also available in (ここにブラウザの言語)」という案内が出ます。

徹底的にリダイレクトする

例としてMicrosoftのウェブサイトを挙げます。

画像はVPNで米国からのアクセスを偽装し、OSもブラウザもアメリカ語にした場合の表示です。en-usでインデックスされていますね。

3. prefix_and_default (デフォルトも接頭辞も)

Next.js (v12.0.7時点) のデフォルト挙動はこれです。

そして、奇妙なことにこの挙動の例が見つけられません。

next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
  i18n: {
    locales: ['ja', 'en'],
    defaultLocale: 'ja',
  }
};

例えば、コーポレートサイトを英語に対応させる状況です。defaultLocalejaに設定しています。

pages/index.tsxこんにちはまたはHelloが出るとしましょう。

URL 結果
/ こんにちは
/ja こんにちは
/en Hello

デフォルトロケールの関係で、日本語コンテンツが2箇所に存在していますね。

canonicalタグであるとはいえ、モバイル向けとかそういう分類ではないから、重複コンテンツじゃないか? と思ったのですが、実際に重複した状態で動いているサイトが見つけられませんでした。

Next.jsでも「全部接頭辞」パターンを使いたい

https://github.com/vercel/next.js/discussions/18419

2020年10月、「すべてのロケールにprefix付けさせてくれ!」というディスカッションが始まりました。

https://github.com/vercel/next.js/discussions/18419#discussioncomment-1559245

そして問題提起からちょうど1年後 に、Next.js 12のMiddlewareを使った解決策が投稿され、ちょっと改良されたものが「アンサー」としてマークされました。

でも全ページで言語検知したい

Next.jsのi18nでは、トップページ以外でlocaleDetectionが動きません。

https://github.com/vercel/next.js/discussions/17078

For example what if a crawler did not set Accept-Language and goes to /nl/blog/hello-world, we'd want it to be indexed and not redirected to /en/blog/hello-world (if en is the defaultLanguage).

これは、言語指定のないクローラーが「デフォルト」に飛ばされ、翻訳ページをガン無視してしまうのを回避するためです。

その結果、上記の実装だと、トップページ以外ではブラウザ言語より/jaが優先されてしまいます。

私はマイクロソフトのように完全なリダイレクトをしたいため、accept-language-parserというパッケージで解決します。

実装

今回の実装では国コードは考えません

パッケージ

https://github.com/opentable/accept-language-parser
yarn add -D @types/accept-language-parser
yarn add accept-language-parser

言語だけを取り出したいので、これが今回の実装では一番都合がいいです。

コンフィグ

next.config.js
module.exports = {
  i18n: {
    locales: ['default', 'ja', 'en'],
    defaultLocale: 'default',
  },
}

まず、defaultというダミーロケールを作ります。

こうしないと、リダイレクトしたか否かの判定ができないため、無限に転送されてしまいます。

pages/_middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import acceptLanguageParser from 'accept-language-parser';

/**
 * 選択肢の中から言語を選ぶ
 * @param localeHeader Accept-Languageヘッダの値
 * @returns 言語だけのコード
 */
function selectLanguage(localeHeader: string | null) {
  // 選択肢
  const languages = ['ja', 'en'];
  const detectedLang = acceptLanguageParser.pick(
    languages,
    localeHeader ?? 'ja-JP',
    { loose: true }
  );
  // いずれも該当しない場合はjaを選ぶ
  return detectedLang ?? languages[0];
}

/**
 * どんな言語でも接頭辞を付ける
 * @param request
 * @see https://nextjs.org/docs/advanced-features/i18n-routing#prefixing-the-default-locale 参考
 * @see next.config.js で「default」をデフォルトロケールにしている
 * @returns
 */
export function middleware(request: NextRequest) {
  const { pathname, locale, search } = request.nextUrl;

  // ファイルは除外
  const PUBLIC_FILE = /\.(.*)$/;

  const shouldHandleLocale =
    !PUBLIC_FILE.test(pathname) &&
    !pathname.includes('/api/') &&
    locale === 'default';

  const language = selectLanguage(request.headers.get('accept-language'));

  return shouldHandleLocale
    ? NextResponse.redirect(`/${language}${pathname}${search}`)
    : undefined;
}

そしてミドルウェアの登場です。_middleware.tsを用意します。 (pageExtensionsを指定している場合_middleware.page.tsなどにする必要があります。)

{ loose: true }を指定することで、言語コードだけが返ってくるようにしています。

検証

Accept-Languageヘッダを使って振り分けます。Chrome 96.0.4664.110で検証しました。

ブラウザの設定 Accept-Language ページ 結果 備考
日本語 ja / 302 Found→/ja
日本語 ja /en 200 OK→/en (転送されない)
英語(米国) en-US,en;q=0.9 / 302 Found→/en
英語(米国) en-US,en;q=0.9 /ja 200 OK→/ja (転送されない)
韓国語 ko / 302 Found→/ja
韓国語 ko /ja 200 OK→/ja (転送されない)
韓国語 ko /en 200 OK→/en (転送されない)

/ja/enが付いていればlocaleがそれに固定されるので、まず転送が発火しません。

ブラウザの設定 Accept-Language ページ 結果
韓国、英語(英国) ko,en-GB;q=0.9,en;q=0.8 / 302 Found→/en
韓国語、日本語、英語(米国) ko,ja;q=0.9,en-US;q=0.8,en;q=0.7 / 302 Found→/ja

複数の言語を指定している場合は、上から順に選ばれます。

localeDetectionを切る必要性が分からない

上記のディスカッションでもドキュメントの例でもlocaleDetection: falseですが、私は切っていません。

どの言語にしても、ちゃんとdefaultロケールから飛ばされています。localeDetection、切らなくていいのでは?

https://github.com/pythonitalia/pycon/pull/2525#discussion_r763892388

同じこと疑問に思ってる方がいたのですが、「推奨されてるから切ってるけど調査中」とのこと。

注意点

next-i18nextを使う場合

defaultロケールを捏造した関係で、public/locales/defaultがないと怒られます。

既存のサイトを改修する場合

既存のサイトをこの方法に切り替えたことによるSEOへの影響は把握していませんし責任を負えません

NextResponse.redirectにステータスを渡さない限り 302リダイレクト(一時的な移動) になります。言語切替としては正しいですが、「永久移行」ではありません。

GitHubで編集を提案

Discussion

ログインするとコメントできます