🌐

[Next.js App Router] i18n対応をnext-i18n-routerだけですっきりさせるメモ

2024/03/21に公開

概要

  • next.js 14 App Routerのアプリケーションでi18n対応したい
  • next公式ドキュメントにあるスクラッチで構築する方法が大変そう
  • サーバーコンポーネントはi18nextで対応、クライアントコンポーネントはreact-i18nextで対応、とそれぞれのライブラリーをセットアップするのもなんだがすっきりしない(ライブラリの名前はかなりややこしいが、next-i18nextもあってそちらはpages routerで使うものなの今回の検討対象外)
  • 試行錯誤の結果、next-i18n-routerと1つのライブラリーだけでいつものようなt('hogehoge.text')をCCもSCも対応できたのでメモ

サンプルをリポジトリも作ったので、過不足指摘大歓迎です!
https://github.com/shomtsm/nextjs14-app-i18n

👉 多言語対応のルーティング設定

1. インストール

npm install next-i18n-router

2. [locale]ディレクトリ

  • app配下に[locale]ディレクトリを作り、既存のすべてのページを[locale]配下へ移動
  • /app/apiなどディレクトリは移動しなくてよい(言語によってエンドポイント変えるのであれば移動だが)

3. /i18n/config.js

  • すきなところにi18nConfig.jsを作る
/i18n/config.js
const i18nConfig = {
  locales: ['en', 'ja', 'zh'],
  defaultLocale: 'ja'
};

module.exports = i18nConfig;
  • デフォルト言語はパスの表示しない
  • 下記の例ではjaがデフォルトで、/aboutなら/aboutそのまま、enやzhの場合はそれぞれ/en/about、/zh/aboutになる
  • localesにない言語設定の場合はデフォルト言語になる

デフォルトlocalのパス表示

/i18n/config.js
const i18nConfig = {
  locales: ['en', 'ja', 'zh'],
  defaultLocale: 'ja',
  prefixDefault: false,  // デフォルトlocalのprefixを付けるかどうか
};
  • prefixDefaultのデフォルト値はfalseで、つまりデフォルト言語のパスは表示しない
  • デフォルト言語でもパス表示する場合はtrue

そのたのconfig

  • localeDetector : リクエストのaccept-languageヘッダーの言語設定
  • localeCookie : ユーザーが手動で選択した言語を記憶するための設定
  • serverSetCookie : ユーザーがlocaleパスで訪問したら、ユーザーの好みlocaleをサーバーで保存するための設定
  • basePath : next.config.jsでbasePathオプションを使用している場合にのみ必要

4. middleware

srcディレクトリがる場合は、src配下に、
srcディレクトリがない場合は、ルート配下に、
middleware.ts(なければ作る)内ルーティング処理を書く

middleware.ts
import { i18nRouter } from 'next-i18n-router'
import i18nConfig from '@/i18n/config'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  return i18nRouter(request, i18nConfig)
}

export const config = {
  matcher: '/((?!api|static|.*\\..*|_next).*)',
}
  • requestをi18nRouterでラップして返す
  • 適用範囲(matcher)は、「api」、「static」、「_next」を含まないパスとなる
  • そのた適応させたくないパスもmatcherに追加できる
middleware.ts
// 例: testディレクトリもi18nのルーティングさせない
export const config = {
  matcher: '/((?!api|static|test|.*\\..*|_next).*)'
};

以上で、/aboutをアクセスすると、/en/aboutにルーティングされる(アプリケーションデフォルトがen以外で、ブラウザ言語が英語の場合)

👉 current localeの取得

サーバーコンポーネントでのcurrent locale取得

interface ExampleServerComponentProps {
  params: {
    locale: string
  }
}
export default function ExampleServerComponent({ params: { locale } }: ExampleServerComponentProps) {
  return (
    <div>
      <p>{locale}</p>
    </div>
  )
}

クライアントコンポーネントでのcurrent locale取得

'use client';

import { useCurrentLocale } from 'next-i18n-router/client';
import i18nConfig from '@/i18n/config';

function ExampleClientComponent() {
  const locale = useCurrentLocale(i18nConfig);

  ...
}

👉 sitemapのi18n対応

next.js app router公式ドキュメント
によると、sitemap.tsからxmlを生成してくれるのでそれを利用する
言語数xページ数の数だけ生成するようにする。

changeFrequencyやpriorityをpathごとで指定し、言語数だけのsitemap生成

  • changeFrequencyやpriorityを指定したいので、手動でpages配列を定義する
  • 言語はi18nConfigから読み取る形でsitemapを生成
src/app/sitemap.ts
import { MetadataRoute } from 'next'
import i18nConfig from '@/i18n/config'

const root = 'https://your-site.com'

type ChangeFrequency =
  | 'weekly'
  | 'yearly'
  | 'monthly'
  | 'always'
  | 'hourly'
  | 'daily'
  | 'never'
  | undefined

const pages: {
  path: string
  changeFrequency: ChangeFrequency
  priority: number
}[] = [
  {
    path: '/',
    changeFrequency: 'weekly',
    priority: 1,
  },
  {
    path: '/contact',
    changeFrequency: 'yearly',
    priority: 0.8,
  },
  {
    path: '/about',
    changeFrequency: 'monthly',
    priority: 0.7,
  },
]

export default function sitemap(): MetadataRoute.Sitemap {
  const sitemapEntries: MetadataRoute.Sitemap = []

  pages.forEach((page) => {
    i18nConfig.locales.forEach((locale) => {
      const path =
        locale === i18nConfig.defaultLocale && i18nConfig.prefixDefault
          ? `/${locale}${page.path}`
          : page.path
      sitemapEntries.push({
        url: `${root}${path}`,
        lastModified: new Date(),
        changeFrequency: page.changeFrequency,
        priority: page.priority,
      })
    })
  })

  return sitemapEntries
}

👉 ユーザーによる言語切替 router.replace

パス書き換えのカスタムフック作成

  • 基本はpathのlocale部分をrouter.replaceで書き換える作業
  • nextのusePathnameからlocaleの部分を探して新しいlangに置き換える
  • ?from=hogehogeなどのパスもuseSearchParamsから取得して維持する
  • i18nConfigでprefixDefault: false(デフォルトlocaleをパスに表示しない)場合はパスの書き換え例外処理忘れなく
  • 上記作業をカスタムフックでカプセル化し、クライアントコンポーネントから呼び出せるようにする
src/hooks/useLang.ts
import { useCurrentLocale } from 'next-i18n-router/client'
import i18nConfig, { type Langs } from '@/i18n/config'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'

export default function useLang() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const defaultLocale = i18nConfig.defaultLocale

  const locale = useCurrentLocale(i18nConfig)
  const locales = i18nConfig.locales
  const changeLang = (lang: Langs) => {
    let newPath = pathname
    if (locale === defaultLocale) {
      if (lang !== defaultLocale) {
        newPath = `/${lang}${pathname}`
      }
    } else {
      newPath = pathname.replace(new RegExp(`^/${locale}(\/|$)`), `/${lang}$1`)
    }

    const searchParamsString = searchParams.toString()
      ? `?${searchParams.toString()}`
      : ''
    router.replace(`${newPath}${searchParamsString}`)
  }

  return { locale, locales, changeLang }
}
  • localeは現在言語の表示や表示辞書の設定に使えるる
  • localesも一緒にカスタムフックから返してもらってるので楽
  • changeLangは言語切り替え用の関数

クライアントコンポーネントで言語切替

src/components/SettingLang.tsx
'use client'
import useLang from '@/hooks/useLang'

export default function Memo() {
  const { locale, locales, changeLang } = useLang()

  return (
    <div>
      <p>{locale} - client</p>
      {locales.map((lang) => (
        <button key={lang} onClick={() => changeLang(lang)}>
          change lang to {lang}
        </button>
      ))}
    </div>
  )
}

👉 localeに応じた辞書を使う<基本形>

jsonの辞書を用意する [lang].json

  • 対応する言語の数だけjson用意すればよい
src/i18n/dictionaries/en.json
{
  "products": {
    "cart": "Add to Cart"
  }
}
src/i18n/dictionaries/ja.json
{
  "products": {
    "cart": "カートに追加"
  }
}

json辞書のモジュール化

/i18n/index.ts 基本形

  • jsonの辞書を読み込んでモジュール化して再利用を簡単にするための関数
  • ファイル名自体なんでもいい
  • importを簡単にするため /i18n/index.tsで定義
type Langs = 'en' | 'ja' | 'zh'
const dictionaries = {
  en: () => import('./dictionaries/en.json').then((module) => module.default),
  ja: () => import('./dictionaries/ja.json').then((module) => module.default),
  zh: () => import('./dictionaries/zh.json').then((module) => module.default),
}

export const getDictionary = async (locale: Langs) => dictionaries[locale]()

これだけでもよいが、type Langs = 'en' | 'ja' | 'zh'のハードコーディングをなくすために、i18nConfigでLangsを自動作成してexport

/i18n/config.ts ts対応+型export

/i18n/config.ts
const i18nConfig = {
  locales: ['en', 'ja', 'zh'] as const, // as constを追加
  defaultLocale: 'ja',
  prefixDefault: false,
}

export type Langs = (typeof i18nConfig.locales)[number] // 型の作成

export default i18nConfig

/i18n/index.ts ハードコーディングなくすver

  • 辞書読み込みはi18nConfig.localesからループ回す
src/i18n/index.ts ハードコーディングなくすver
import i18nConfig, { type Langs } from '@/i18n/i18nConfig'

const dictionaries = i18nConfig.locales.reduce((acc, lang) => {
  acc[lang] = () =>
    import(`./dictionaries/${lang}.json`).then((module) => module.default)
  return acc
}, {} as { [K in Langs]: () => Promise<any> })

export const getDictionary = async (locale: Langs) => dictionaries[locale]()

現在言語の辞書を使う<基本形>

基本はlocale渡して対応するjsonを取得する

ServerComponent.tsx 基本形
import { getDictionary } from '@/i18n'

export default function ServerComponent() {
  const { locale } = params
  const dict = await getDictionary(locale)

  return (
    <div>{dict.products.cart}</div>
  )
}

これだとサーバーサイトコンポーネントでしか動かないのが困るので
→ layout.tsxで取得してAppContextにわたす

  • layout.tsxにhtmlタグやmeta情報をセットアップするのでserver componentであること
  • app/[locale]/layout.tsxとサーバー側で言語取得できること
  • パスに応じてサーバー側で辞書を準備してクライアントのcontextに渡して全体に使い回す
    というわけで以下の対応で辞書をcontextで任意クライアントコンポーネントに配れるようにする

サーバーサイドコンポーネントからdictをクライアントコンポーネントにわたす

/app/[locale]/layout.tsx
import { type Langs } from '@/i18n/config'
import { getDictionary } from '@/i18n'
import { AppProvider } from '@/contexts/AppContext'

export const metadata: Metadata = {
  title: 'Your Application Name',
  description: 'Your description',
}

interface RootLayoutProps {
  children: React.ReactNode
  params: { locale: Langs }
}

export default async function RootLayout({
  children,
  params,
}: Readonly<RootLayoutProps>) {
  const { locale } = params
  const dict = await getDictionary(locale)

  return (
    <html lang={locale}>
      <body>
        <AppProvider dict={dict}>{children}</AppProvider>
      </body>
    </html>
  )
}

クライアントサイド・コンポーネント AppContext.tsx

/contexts/AppContext.tsx
'use client'
import { createContext, useContext } from 'react'

interface AppContextType {
  dict: { [key: string]: any }
}
const AppContext = createContext<AppContextType>({
  dict: {},
})

export const useAppContext = () => useContext(AppContext)

export const AppProvider = ({
  children,
  dict,
}: {
  children: React.ReactNode
  dict: { [key: string]: any }
}) => {
  return <AppContext.Provider value={{ dict }}>{children}</AppContext.Provider>
}

AppContextにラップされたクライアントコンポーネントで使う

SampleClientComponenet.tsx
'use client'
import { useAppContext } from '@/contexts/AppContext'

export default function SampleClientComponenet() {
  const { t } = useAppContext()

  return (
    <div>
      <button>{t.products.cart}</button>
    </div>
  )
}

👉 localeに応じた辞書を使う<改良版>

上の基本形でも一通り動くが、困ったこと2つある

  1. dict.product.cartsのような書き方は見慣れているt('product.carts')になっていないのでぱっとみこれが文字列であることがわかりづらいし、キーにハイフォンがはいっているとdict.product['reset-cart']で書かなきゃいけないのも格好悪い
  2. 存在しないキーにアクセスしても、画面上は何も表示されないだけでまったく気づかない。t('product.something-undefined')となにか存在しないキーにアクセスしていたら、そのままの文字列'product.something-undefined'を表示したい

上記2点を改良するために試行錯誤して、自分のなかのベストプラクティスは以下

t('product.carts')形式での対応

  • getDictionary(): i18nConfigから言語取得して配列をまわして辞書をモジュール化するところは同じ
  • getTranslation()関数で、t('product.carts')の形で文字列を取り出せるようにし、存在しないプロパティは引数そのまま返す
  • createTranslator()関数で、サーバーコンポーネントでのgetDictionaryとgetTranslation処理を1つにまとめる
src/i18n/index.ts
import i18nConfig, { type Langs } from '@/i18n/i18nConfig'

const dictionaries = i18nConfig.locales.reduce((acc, lang) => {
  acc[lang] = () =>
    import(`./dictionaries/${lang}.json`).then((module) => module.default)
  return acc
}, {} as { [K in Langs]: () => Promise<any> })

export const getDictionary = async (locale: Langs) => dictionaries[locale]()

export const getTranslation = (dict: any, path: string) => {
  const keys = path.split('.')
  let result = dict
  for (let key of keys) {
    if (result === undefined) {
      return path
    }
    result = result[key]
  }
  if (typeof result !== 'string') {
    return path
  }
  return result
}

export const createTranslator = async (locale: Langs) => {
  const dict = await getDictionary(locale)
  return (path: string) => getTranslation(dict, path)
}

カスタムフック /hooks/useTranslation.tsx (クライアントコンポーネント用)

  • 辞書をアクセスするためのカスタムフックuseTranslationを作成
  • useTranslationの中に更にuseAppContextのカスタムフックを呼び出して辞書を受け取る(layout.tsxでgetDictionary()で辞書取得してAppProviderにわたすところは一緒)
src/hooks/useTranslation.tsx
import { useAppContext } from '@/contexts/AppContext'
import { getTranslation } from '@/i18n'

export default function useTranslation() {
  const { dict } = useAppContext()

  const translate = (path: string) => getTranslation(dict, path)

  return { t: translate }
}

サーバーコンポーネントで使うとき const t = await createTranslator(locale)

import { type Langs } from '@/i18n/config'
import { createTranslator } from '@/i18n'

interface ExampleServerComponentProps {
  params: { locale: Langs }
}
export default async function ExampleServerComponent({
  params: { locale },
}: ExampleServerComponentProps) {
  const t = await createTranslator(locale)
  return (
    <div>
      <p>{locale} - server</p>
      <button>server - {t('products.cart')} - 表示できる</button>
      <button>server - {t('products.cart3')} - 存在しないキーはそのまま</button>
      <button>server - {t('products')} - ネストされているキー自体もそのまま帰す</button>
    </div>
  )
}

クライアントコンポーネントで使うとき const { t } = useTranslation()

'use client'
import useLang from '@/hooks/useLang'
import useTranslation from '@/hooks/useTranslation'

export default function Memo() {
  const { locale } = useLang()
  const { t } = useTranslation()

  return (
    <div>
      <p>{locale} - client</p>
      <button>client - {t('products.cart')} - 表示できる</button>
      <button>client - {t('products.cart3')} - 存在しないキーはそのまま</button>
      <button>client - {t('products')} - ネストされているキー自体もそのまま帰す</button>
    </div>
  )
}

以上。
お疲れ様です!

参考ドキュメント・参考記事

Discussion