🔠

Next.js(App Router)での国際化対応

2024/01/01に公開

成果物

https://github.com/underground0930/nextjs-i18next

参考にさせて頂いた記事

https://blog.arthur1.dev/entry/2023/06/04/100000
https://qiita.com/johnmackay150/items/88654e5064290c24a32a

主な使用ライブラリ

Next.jsの他にこれらをインストールしました。

実装の詳細

翻訳テキストを扱うためのhooksの用意

langtranslationをpropsとして受け取り、
翻訳データを返すメソッドと、i18nインスタンスを返します。
defaultLanguageは、他の言語で翻訳テキストが指定されていない場合、表示される言語になります。
言語が増やす場合に、otherLanguagesの配列に追加していきます。

src/hooks/i18n/useTranslation.ts
import { createInstance } from 'i18next'
import { Metadata } from 'next'
import { initReactI18next } from 'react-i18next/initReactI18next'

export const defaultLanguage = 'ja' as const
export const otherLanguages = ['en'] as const
export const languages = [defaultLanguage, ...otherLanguages] as const
export type Languages = (typeof languages)[number]

export type TranslationDef = {
  [L in Languages]: {
    meta: Metadata
    translation: {
      [key: string]: any
    }
  }
}

export const useTranslation = ({
  lang,
  translation,
}: {
  lang: Languages
  translation: TranslationDef
}) => {
  const i18n = createInstance()
  void i18n.use(initReactI18next).init({
    resources: translation,
    lng: lang,
    fallbackLng: defaultLanguage,
    supportedLngs: languages,
    debug: false,
  })

  return {
    t: i18n.getFixedT(lang),
    i18n,
  }
}

langを管理するContextとProviderを用意

コード全体で、現在どの言語かを判定するためのLanguagesを管理するための
ContextとProviderを用意します。

src/providers/LangProvider.tsx
'use client'

import { Languages } from '@/hooks/i18n'
import { createContext } from 'react'

export const LangContext = createContext<Languages | null>(null)

export function LangProvider({ children, lang }: { children: React.ReactNode; lang: Languages }) {
  return <LangContext.Provider value={lang}>{children}</LangContext.Provider>
}

RootLayoutにLangProviderをセットする

多言語対応したいページは、[lang] 配下に格納する事で、paramsから言語の文字列が取得出来ます。ページ毎にpropsでバケツリレーしても良いですが、この値をProviderにセットすればサイト全体で使用出来ます。

src/app/[lang]/layout.tsx

import { Inter } from 'next/font/google'
import '../globals.css'

import type { Languages } from '@/hooks/i18n'
import { LangProvider } from '@/providers'

const inter = Inter({ subsets: ['latin'] })

export default function RootLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: { lang: Languages }
}) {
  const { lang } = params
  return (
    <html lang={lang}>
      <body className={inter.className}>
        <LangProvider lang={lang}>{children}</LangProvider>
      </body>
    </html>
  )
}

useContextのラッパーのhooksを用意する

useContextをそのまま使用すると、tsで
langにnullの可能性があるという事で、
毎回langを使用する度に型ガードの実装が必要になり面倒です。
また、LangProviderの外でこのhookを使うと、エラーを投げてくれます。

src/hooks/lang/useLang.ts
import { LangContext } from '@/providers'
import { useContext } from 'react'

export function useLang() {
  const lang = useContext(LangContext) // "ja" | "en" | null
  if (lang === null) throw new Error('useLang must be used within a LangProvider')
  return lang // "ja" | "en"
}

ページのコンポーネントを用意する

src/app/[lang]/(top)/page.tsx
import { TopPage } from './_components/TopPage'

import { topTranslation as translation } from './_translations'
import { setMetadata } from '@/utils'

export const generateMetadata = setMetadata(translation)

export default function Page() {
  return <TopPage />
}

動的なURLのパラメータに応じて、metaも設定したいので、
Next.jsが用意している、generateMetadataをページ毎に設定する必要があります。
util関数として、setMetadataを設定しました。
generateMetadataを使用する場合はServer Componentにしなくてはいけません。
なのでページの詳細の実装は別コンポーネントに切り出しています(今回はTopPageコンポーネント)。

_translationsにはページ固有の翻訳テキストデータが格納されています。

https://nextjs.org/docs/app/api-reference/functions/generate-metadata#generatemetadata-function

本題とはズレますが、(top)のようにRoute Groupsを設定することが出来ます。
これはURLに影響を与えないのでファイルのグルーピングに便利です。

https://nextjs.org/docs/app/building-your-application/routing/colocation#route-groups

今回でいうとトップページで使用するファイルは以下のディレクトリにまとめています。

src/app/[lang]/(top)/

Private Folders

src/app/[lang]/(top)/
├── _components
│   ├── TopPage.tsx
│   └── form
│       ├── ErrorText.tsx
│       ├── Input.tsx
│       ├── Label.tsx
│       └── index.ts
├── _translations
│   └── index.ts
├── _types
│   ├── Inputs.tsx
│   └── index.ts
└── page.tsx

ColocationパターンといわれるNext.jsの新しいディレクトリ構造なのですが、
関係ファイルを一箇所にまとめることにより他のページと関係が疎になるので、「捨てやすい」 アプリケーションを実装することが出来ます。

共通のmeta設定用のutil関数

ページ毎のgenerateMetadata関数を返す関数。
langはurlパラメータから自動的に取得出来ます。
翻訳データをページ毎に設定します。

src/utils/setMetadata.ts

import { Languages, TranslationDef } from '@/hooks/i18n'

export const setMetadata =
  (def: TranslationDef) =>
  ({
    params,
  }: {
    params: {
      lang: Languages
    }
  }) => {
    const { title, description } = def[params.lang].meta
    return {
      title,
      description,
    }
  }

Linkコンポーネントを翻訳用にカスタマイズ

先ほど用意したuseLanghookを使って、Linkコンポーネントもカスタマイズします。
これでどの言語ページにいても、意識することなくLinkを設定出来ます。

src/components/LangLink.tsx
'use client'

import { useLang } from '@/hooks/lang'
import Link from 'next/link'

type Props = Readonly<{
  href: string
  children?: React.ReactNode
}> &
  React.ComponentPropsWithoutRef<'a'>

export function LangLink({ href, children, ...props }: Props) {
  const lang = useLang()
  return (
    <Link {...props} href={`/${lang}${href}`}>
      {children}
    </Link>
  )
}

ページ固有の翻訳データを用意する

jaen以下に対になるようにテキストを用意します。
metaには各ページのmetaをtranslationにはコンテンツ部分の翻訳を設定します。
i18nextでは、{{count}}のように書くと、変数を文字列の中に埋め込めるので、動的にテキストが変わる場合に便利です。

src/app/[lang]/(top)/_translations/index.ts

const texts = {
  ja: {
    meta: {
      title: 'トップ',
      description: 'トップページです',
    },
    translation: {
      lead: '本文になります',
      button: 'カウント追加ボタン',
      count: 'カウント: {{count}}!',
      name: '名前',
      age: '年齢',
      submit: '送信',
      validates: {
        name: '名前は{{min}}文字以上{{max}}文字以下で入力してください',
        age: '年齢は{{min}}歳以上{{max}}歳以下で入力してください',
      },
    },
  },
  en: {
    meta: {
      title: 'Top',
      description: 'This is top page',
    },
    translation: {
      lead: 'This is lead',
      button: 'Add Count Button',
      count: 'Count: {{count}}!',
      name: 'Name',
      age: 'Age',
      submit: 'submit',
      validates: {
        name: 'Please enter a name between {{min}} and {{max}} characters',
        age: 'Please enter an age between {{min}} and {{max}}',
      },
    },
  },
}

const topTranslation = { ...texts } as const

export { topTranslation }

これまでの設定を元に、実際のページで使用してみる

フォームなどのバリデートの翻訳は厄介ですが、
react-hook-formzodResolverを使うと割とスッキリ書けました。

src/app/[lang]/(top)/_components/TopPage.tsx
'use client'

import { zodResolver } from '@hookform/resolvers/zod'
import { useState } from 'react'
import { useForm, SubmitHandler } from 'react-hook-form'
import { z } from 'zod'

// ページ共通
import { useTranslation } from '@/hooks/i18n'
import { useLang } from '@/hooks/lang'
import { setMetadata } from '@/utils'
import { LangLink } from '@/components'

// ページ固有
import { topTranslation as translation } from '../_translations'
import type { Inputs } from '../_types'
import { Input, Label, ErrorText } from '../_components/form'

// metaの設定
export const generateMetadata = setMetadata(translation)

// 入力の文字列の長さ
const inputLength = {
  name: {
    min: 1,
    max: 20,
  },
  age: {
    min: 1,
    max: 99,
  },
}

// zodでバリデート
const inputSchema = (errors: string[]) =>
  z.object({
    name: z
      .string()
      .min(inputLength['name']['min'], errors[0])
      .max(inputLength['name']['max'], errors[0]),
    age: z.string().refine((data) => {
      const parsedNumber = parseInt(data)
      return (
        !isNaN(parsedNumber) &&
        parsedNumber >= inputLength['age']['min'] &&
        parsedNumber <= inputLength['age']['max']
      )
    }, errors[1]),
  })

export function TopPage() {
  const lang = useLang() // `ja` or `en`
  const { t } = useTranslation({ lang, translation })
  const [count, setCount] = useState(0)

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<Inputs>({
    resolver: zodResolver(
      inputSchema([
        t('validates.name', {
          min: inputLength['name']['min'],
          max: inputLength['name']['max'],
        }),
        t('validates.age', {
          min: inputLength['age']['min'],
          max: inputLength['age']['max'],
        }),
      ]),
    ),
  })

  const onSubmit: SubmitHandler<Inputs> = (data) => {
    const { name, age } = data
    console.log(name, age) // submit処理
  }

  return (
    <div className='flex min-h-screen flex-col items-center justify-between p-24'>
      <div>
        <h1 className='mb-5'>{t('lead')}</h1>
        <h2 className='mb-5'>{t('count', { count })}</h2>
        <div className='border-2'>
          <button className='block w-full' onClick={() => setCount((prev) => prev + 1)}>
            {t('button')}
          </button>
        </div>
        <h2 className='mt-5 mb-5 text-center'>Link Sample</h2>
        <ul className='flex justify-center gap-4'>
          <li>
            <LangLink href='/detail'>Detail Link &gt;</LangLink>
          </li>
        </ul>
        <h2 className='mt-5 mb-5 text-center'>Form Sample</h2>
        <div className='flex-1'>
          <form
            onSubmit={(e) => {
              e.preventDefault()
              handleSubmit(onSubmit)(e)
            }}
          >
            <div className='mb-5'>
              <Label>{t('name')}</Label>
              <Input {...register('name')} />
              {errors.name?.message && <ErrorText error={errors.name.message} />}
            </div>
            <div className='mb-5'>
              <Label>{t('age')}</Label>
              <Input {...register('age')} />
              {errors.age?.message && <ErrorText error={errors.age.message} />}
            </div>
            <div className='flex justify-center'>
              <button className='w-[100px] h-10 text-white bg-[#bbb]' type='submit'>
                {t('submit')}
              </button>
            </div>
          </form>
        </div>
      </div>
    </div>
  )
}

実際の表示

http://localhost:3000/en
http://localhost:3000/ja

最後に

国際化対応をする時の参考になれば幸いです。
もっと良い方法があれば是非教えてください🙏🙏🙏

Discussion