🌍

Next.js App Router で i18n に対応する

2023/12/10に公開

Next.js の App router で i18n 対応を実施する際に諸々試してみたので記事にまとめます。Next.js の公式で紹介されているライブラリを利用しない形での実現方法と、next-intl を利用したパターンの二つを実践してみたため、その際に考えたことを整理します。なお今回はサブドメインやトップドメインではなく、サブディレクトリを利用した言語の切り替えのユースケースを見ていきたいと思います。

Next.js の機能のみで実現する場合

i18n 対応といった時に今回は主に二つのことを考えたいと思います。一つ目は、リクエストされたパスに言語が含まれていなかった際にどのようにリダイレクトをするか、二つ目は、どのように辞書情報を利用するかについてです。これから紹介する Next.js 単体で i18n 対応をしようとする場合、公式ドキュメントが非常に参考になります。

リダイレクト

Next.js App Router では、デフォルトでストリーミングにより、クライアントにHTMLの一部分を段階的に返却するようになっており、これにより、データフェッチを行う部分などはあとから返却できるためFCPの削減することができます。
ストリーミングはユーザーにより早くコンテンツを届ける意味で重要な機能ですが、その性質上あるサーバーコンポーネントで redirect 関数などを呼んだとしてもステータスコードは 200 を返す可能性があります。そのため、リクエストの完了前にパスを検証し適切な言語に振り分ける処理は middleware で実行する様になります。

middleware.ts
// 対応する言語の定義
let locales = ['en-US', 'nl-NL', 'nl']
 
// ユーザーの選好する言語設定をrequestから取得するmethod
function getLocale(request) { ... }
 
export function middleware(request) {
  // 対応する言語設定が含まれているかチェック
  const { pathname } = request.nextUrl
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )
 
  if (pathnameHasLocale) return
 
  // パスに言語が含まれていなかった場合、redirectする
  const locale = getLocale(request)
  request.nextUrl.pathname = `/${locale}${pathname}`
  // リクエストが /products の場合
  // リダイレクト先は例えば /en-US/products になる
  return Response.redirect(request.nextUrl)
}

公式の参考実装では、getLocale 関数に negotiator ライブラリを利用する以外はNext.jsのみの機能で実現できており、実装も middleware にそこまで複雑でない処理を書くのみで済むため体験としてはかなり良いです。

辞書

次に言語の割り振りができる様になったとして、どの様にその言語の翻訳を HTML に反映していくかという部分です。多くのケースでは、各言語ごとに json ファイルを作成し、パスの言語情報を利用して翻訳した文字列を利用することになります。

dictionaries/en.json
{
  "products": {
    "cart": "Add to Cart"
  }
}
dictionaries/ja.json
{
  "products": {
    "cart": "カートに追加"
  }
}

上記の様に定義した辞書を利用するためgetDictionary 関数を利用定義することで、実際にReact コンポーネント上で「カートに追加」と「Add to Cart」をパスに応じて切り替えることが可能になります。

app/[lang]/dictionary.ts
import 'server-only'
 
const dictionaries = {
  en: () => import('./dictionaries/en.json').then((module) => module.default),
  nl: () => import('./dictionaries/ja.json').then((module) => module.default),
}
 
export const getDictionary = async (locale) => dictionaries[locale]()
app/[lang]/page.tsx
import { getDictionary } from './dictionaries'
 
export default async function Page({ params: { lang } }) {
  const dict = await getDictionary(lang) // ja
  return <button>{dict.products.cart}</button> // カートに追加
}

ライブラリを利用した場合

次にライブラリを利用したケースについて見ていきます。今回はスターの数や、ドキュメントの読みやすさ、App Router への対応状況などから next-intl を利用してみることにします。

リダイレクト

さきほど記載した通り、Middleware にてリダイレクトという部分は変わりません。ただし、next-intl 側で Middleware の作成をラップした API を提供してもらえることから、設定を簡潔に記載できます。

middleware.ts
import createMiddleware from 'next-intl/middleware';
 
export default createMiddleware({
  // サポートする言語の一覧
  locales: ['en', 'de'],
 
  // defaultの言語
  defaultLocale: 'en'
});
 
export const config = {
  // 国際化対応を行うpath
  matcher: ['/', '/(de|en)/:path*']
};

辞書

next-intl では next.config.ts に以下の様に設定を追記することで、セットアップを行います。

next.config.ts
const withNextIntl = require('next-intl/plugin')();
 
module.exports = withNextIntl({
  // next.config.tsに記載していた元々の設定
});

また、以下の様にi18n.tsに辞書を解決するための設定を記載することで、next-intl が用意している Hooks API を利用することができる様になります。

i18n.ts
import {getRequestConfig} from 'next-intl/server';
 
export default getRequestConfig(async ({locale}) => ({
  messages: (await import(`../messages/${locale}.json`)).default
}));

実際に useTranslations を利用して、辞書を呼び出す様にする場合は以下の様になります。useTranslationsは Server Component でも呼び出すことができるので、呼び出しは必ずしも Client Component に限定されません。

app/[locale]/page.tsx
import {useTranslations} from 'next-intl';
 
export default function Index() {
  const t = useTranslations('Index');
  return <h1>{t('title')}</h1>;
}

ただし、非同期通信を必要とする様な Server Component では useTranslations を利用することができません。page.tsx には、そのページで用いるデータを取得する処理が書かれることも多いかと思いますので、そういったユースケースでは getTranslations 経由で翻訳を取得することになります。

app/[locale]/profile/page.tsx
import {getTranslations} from 'next-intl/server';
 
export default async function ProfilePage() {
  const user = await fetchUser();
  const t = await getTranslations('ProfilePage');
 
  return (
    <PageLayout title={t('title', {username: user.name})}>
      <UserDetails user={user} />
    </PageLayout>
  );
}

ライブラリを積極的に利用したくなる理由

ここまで、Next.js 公式の参考実装と next-intl を利用したケースでの実装の仕方を紹介しましたが、単純に実装が easy になるということを除いてもいつくかの理由でライブラリを使用したくなる理由があると思います。

補完

Next.js 公式の実装方法では、i18n 対応に典型的に存在するいくつかの問題に対処することができていません。そのうちのひとつが辞書に変数を指定して、補完を行い、補完対象を適切にローカライズすることです。

例えば、数値を補完するケースでは典型的には複数形の存在する言語の問題があります。数値の出しわけに対し柔軟な API を適切に実装するのはやや手間な実装かと思います。複数形には値が1かそれ以外かという単純なケースでは対処しきれない国も存在し、shopify が作成している記事では、ポーランド語において1, 2, >4のケースで複数形表現がそれぞれ異なる例を紹介しています。
next-intl などのライブラリは数値の分岐処理を辞書に表現できるため、補完を有効活用しようとした際の実装コスト削減が嬉しい点です。

en.json
"message": "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}."
t('message', {count: 3580}); // "You have 3,580 followers."

バケツリレーの回避

もうひとつ実装している際に気になる点として、言語を区分するために利用しているパラメータをありとあらゆる箇所でバケツリレーしていくことになる点です。useTranslations を用いれば、翻訳にアクセスしたいコンポーネントに言語用のパラメータを渡すようにしていくことなく実装を行うことができ、Component を作成する際の小さなストレスが減ることが嬉しい点だと感じました。

app/_component/cart.tsx
import { getDictionary } from './dictionaries'
 
export default async function Cart({ params: { lang } }) {
  const dict = await getDictionary(lang)
  return <button>{dict.products.cart}</button>
}
app/_component/cart.tsx
import {useTranslations} from 'next-intl';
 
export default function Cart() {
  const t = useTranslations('products')
  return <button>{t('cart')}</button>
}

ユースケース

最後に自分が実装している際に出会った幾つかのユースケースを紹介します。

Form Validation の翻訳

入力フォームのバリデーションエラー時に、ユーザーへ与えるフィードバックも適切に翻訳したいケースがあります。そういったケースでは、フォームのスキーマを生成する hooks を作成するのがシンプルでよいと思いました。

app/[locale]/form/schema.ts
import { useTranslations } from 'next-intl';
import { object, string } from 'yup';

export const useFormSchema = () => {
  const t = useTranslations('form');

  return object().shape({
    name: string().required(t('name.validation.required')),
    email: string().email(t('mail.validation.invalid')).required(t('mail.validation.required'))
  });
};

metadata の翻訳

Next.js はページの metadata を metadata または generateMetadata を経由して生成するようになっています。 言語に応じて動的に metadata の値を変えたいようなケースではgenerateMetadata を利用することで実装できます。generateMetadata は Promise<Metadata> を返す非同期関数であり、非同期の通信のなかでは先ほど述べた通り getTranslations を利用して翻訳を取得します。

app/[locale]/page.tsx
import {getTranslations} from 'next-intl/server';

export async function generateMetadata() {
  const t = await getTranslations('page.home.seo');
  return {
    title: t('title'),
    description: t('description'),
  };
}

フォローもぜひ🙏 @takuumi7

Discussion