🍳

Next.js App Router (app ディレクトリ)使った多言語対応

2023/08/22に公開

Next.js 13 の App Router(appディレクトリ)を使用した多言語対応に関する記事です。こちらの記事を参考に構築しました。
https://dev.to/adrai/i18n-with-nextjs-13-and-app-directory-18dm

前提条件

  • Next.jsアプリ作成済み
  • App Router(appディレクトリ)を使用する
  • JavaScript(TypeScriptは使わない)
  • 日本語と英語に対応する

構築していく

パッケージをインストール

多言語対応に必要なパッケージインストールをします(npmの場合)

npm install i18next react-i18next i18next-resources-to-backend accept-language

フォルダ構成

言語を URL パラメーターとして使います。動的ルーティングに関する不明点はDynamic Routesに関するドキュメントを読みます。

.
└── app
    └── [lng]
        ├── about
        |   └── page.js
        ├── layout.js
        └── page.js
    └── i18n
        └── locales
            ├── ja
            |   ├── home.json
            |   └── about.json
            └── en
                ├── home.json
                └── about.json
	└── index.js
	└── settings.js
   └── components
         └── Footer
            └── index.js
		

ホームページ

app/[lng]/page.js
import Link from 'next/link'

export default function Page({ params: { lng } }) {
  return (
    <>
      <h1>ホームページです</h1>
      <Link href={`/${lng}/about`}>
       About page
      </Link>
    </>
  )
}

Aboutページ

app/[lng]/about/page.js
import Link from 'next/link'

export default function Page({ params: { lng } }) {
  return (
    <>
      <h1>Aboutページです</h1>
      <Link href={`/${lng}`}>
        back
      </Link>
    </>
  )
}

layout.js

app/[lng]/layout.js
import { dir } from 'i18next'

const languages = ['ja', 'en']

export async function generateStaticParams() {
  return languages.map((lng) => ({ lng }))
}

export default function RootLayout({
  children,
  params: {
    lng
  }
}) {
  return (
    <html lang={lng} dir={dir(lng)}>
      <head />
      <body>
        {children}
      </body>
    </html>
  )
}

言語の検出

cookie に言語設定を保存して、設定言語がどのページにも反映されるようにします。
i18nフォルダ内にsettings.jsを作成し、layout.jsを編集します。

app/i18n/settings.js
export const fallbackLng = 'ja'
export const languages = [fallbackLng, 'en']
app/[lng]/layout.js

import { dir } from 'i18next'
+ import { languages } from '../i18n/settings'

export async function generateStaticParams() {
  return languages.map((lng) => ({ lng }))
}

export default function RootLayout({
  children,
  params: {
    lng
  }
}) {
  return (
    <html lang={lng} dir={dir(lng)}>
      <head />
      <body>
        {children}
      </body>
    </html>
  )
}

middleware.jsを作成します。

middleware.js
import { NextResponse } from 'next/server'
import acceptLanguage from 'accept-language'
import { fallbackLng, languages } from './app/i18n/settings'

acceptLanguage.languages(languages)

export const config = {
  matcher: '/:lng*'
}

const cookieName = 'i18next'

export function middleware(req) {
  let lng
  if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName).value)
  if (!lng) lng = acceptLanguage.get(req.headers.get('Accept-Language'))
  if (!lng) lng = fallbackLng

  if (req.nextUrl.pathname === '/') {
    return NextResponse.redirect(new URL(`/${lng}`, req.url))
  }

  if (req.headers.has('referer')) {
    const refererUrl = new URL(req.headers.get('referer'))
    const lngInReferer = languages.find((l) => refererUrl.pathname.startsWith(`/${l}`))
    const response = NextResponse.next()
    if (lngInReferer) response.cookies.set(cookieName, lngInReferer)
    return response
  }

  return NextResponse.next()
}

i18n

i18nフォルダ内にindex.jsを作成します。

app/i18n/index.js
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { getOptions } from './settings'

const initI18next = async (lng, ns) => {
  const i18nInstance = createInstance()
  await i18nInstance
    .use(initReactI18next)
    .use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
    .init(getOptions(lng, ns))
  return i18nInstance
}

export async function useTranslation(lng, ns, options = {}) {
  const i18nextInstance = await initI18next(lng, ns)
  return {
    t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
    i18n: i18nextInstance
  }
}

app/i18n/settings.jsにオプションを追加します。

app/i18n/settings.js
export const fallbackLng = 'ja'
export const languages = [fallbackLng, 'en']
+ export const defaultNS = 'home'

+ export function getOptions (lng = fallbackLng, ns = defaultNS) {
+  return {
+    supportedLngs: languages,
+    fallbackLng,
+    lng,
+    fallbackNS: defaultNS,
+    defaultNS,
+    ns
+  }
+ }

翻訳ファイルの用意

ホームページ、Aboutページそれぞれの翻訳ファイルを用意します。
defaultNS = 'home'と設定しているので、デフォルトではhome.jsonが呼ばれます。

ホームページ

app/i18n/locales/ja/home.json
{
  "title": "ホームページです",
  "to-about-page": "Aboutページへ"
}
app/i18n/locales/en/home.json
{
  "title": "HomePage",
  "to-about-page": "To about page"
}

Aboutページ

app/i18n/locales/ja/about.json
{
  "title": "Aboutページです",
  "back": "もどる"
}
app/i18n/locales/en/about.json
{
  "title": "AboutPage",
  "back": "back"
}

各ページに反映させる

app/[lng]/page.js
import Link from 'next/link'
import { useTranslation } from '../i18n'

export default async function Page({ params: { lng } }) {
+ const { t } = await useTranslation(lng)
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}/about`}>
        {t('to-about-page')}
      </Link>
    </>
  )
}
app/[lng]/about/about.js
import Link from 'next/link'
import { useTranslation } from '../../i18n'

export default async function Page({ params: { lng } }) {
//デフォルトの翻訳ファイル以外を呼ぶときは追記する。今回は'about'
+ const { t } = await useTranslation(lng, 'about')
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}`}>
        {t('back')}
      </Link>
    </>
  )
}

言語切り替えボタンの作成

コンポーネント作成

Footerコンポーネントを作成します。この中に言語切替するための部品を作ります。

app/[lng]/components/Footer/index.js
import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages } from '../../../i18n/settings'
import { useTranslation } from '../../../i18n'

export const Footer = async ({ lng }) => {
  const { t } = await useTranslation(lng, 'footer')
  return (
    <footer style={{ marginTop: 50 }}>
      <Trans i18nKey="languageSwitcher" t={t}>
        Switch from <strong>{{lng}}</strong> to:{' '}
      </Trans>
      {languages.filter((l) => lng !== l).map((l, index) => {
        return (
          <span key={l}>
            {index > 0 && (' or ')}
            <Link href={`/${l}`}>
              {l}
            </Link>
          </span>
        )
      })}
    </footer>
  )
}

Footerコンポーネント用の翻訳ファイルを日英用意

app/i18n/locales/ja/footer.json
{
  "languageSwitcher": "言語切替<1>{{lng}}</1>: "
}
app/i18n/locales/en/footer.json
{
  "languageSwitcher": "Switch from <1>{{lng}}</1> to: "
}

各ページにFooterコンポーネントを追加

app/[lng]/page.js
import Link from 'next/link'
import { useTranslation } from '../i18n'
+ import { Footer } from './components/Footer'

export default async function Page({ params: { lng } }) {
  const { t } = await useTranslation(lng)
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}/about`}>
        {t('to-about-page')}
      </Link>
+     <Footer lng={lng}/>
    </>
  )
}
app/[lng]/about/about.js
import Link from 'next/link'
import { useTranslation } from '../../i18n'
+ import { Footer } from '../components/Footer'

export default async function Page({ params: { lng } }) {
  const { t } = await useTranslation(lng, 'about')
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}`}>
        {t('back')}
      </Link>
+     <Footer lng={lng}/>
    </>
  )
}

パスの設定の追加

今のままだと、ホームページの方は問題なく動作するはずです。
一方、Aboutページは言語切り替えするとホームページに遷移してしまうので、パスの設定の追加をします。

app/[lng]/components/Footer/index.js
import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages } from '../../../i18n/settings'
import { useTranslation } from '../../../i18n'

export const Footer = async ({ lng }) => {
  const { t } = await useTranslation(lng, 'footer')
  return (
    <footer style={{ marginTop: 50 }}>
      <Trans i18nKey="languageSwitcher" t={t}>
        Switch from <strong>{{lng}}</strong> to:{' '}
      </Trans>
      {languages.filter((l) => lng !== l).map((l, index) => {
        return (
          <span key={l}>
            {index > 0 && (' or ')}
-           <Link href={`/${l}`}>
-             {l}
-           </Link>
+	    <Link href={`/${l}${path}`}>{l}</Link>
          </span>
	 
        )
      })}
    </footer>
  )
}
app/[lng]/about/about.js
import Link from 'next/link'
import { useTranslation } from '../../i18n'
import { Footer } from '../components/Footer'

export default async function Page({ params: { lng } }) {
  const { t } = await useTranslation(lng, 'about')
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}`}>
        {t('back')}
      </Link>
-     <Footer lng={lng} />
+     <Footer lng={lng} path="/about"/>
    </>
  )
}

注意点

next.config.jsの設定

next.config.jsにi18nに関する設定を追記していないか確認する。
他の構築方法を試す過程で追記し、削除し忘れていないか注意

翻訳ファイルはjson

翻訳ファイルはjsonにする。他のファイルの拡張子につられてjsにしない。

参考記事

https://dev.to/adrai/i18n-with-nextjs-13-and-app-directory-18dm

https://zenn.dev/yumemi_inc/articles/next-13-app-overview

https://developer.mozilla.org/ja/docs/Glossary/I18N

https://next-intl-example-next-13-git-feat-next-13-rsc-amannn.vercel.app/

https://next-intl-docs.vercel.app/docs/getting-started/app-router-client-components

Discussion