Next.js(App Router)での国際化対応
成果物
参考にさせて頂いた記事
主な使用ライブラリ
Next.jsの他にこれらをインストールしました。
実装の詳細
翻訳テキストを扱うためのhooksの用意
lang
とtranslation
をpropsとして受け取り、
翻訳データを返すメソッド
と、i18nインスタンス
を返します。
defaultLanguage
は、他の言語で翻訳テキストが指定されていない場合、表示される言語になります。
言語が増やす場合に、otherLanguages
の配列に追加していきます。
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を用意します。
'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にセットすればサイト全体で使用出来ます。
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を使うと、エラーを投げてくれます。
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"
}
ページのコンポーネントを用意する
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
にはページ固有の翻訳テキストデータが格納されています。
本題とはズレますが、(top)のようにRoute Groups
を設定することが出来ます。
これはURLに影響を与えないのでファイルのグルーピングに便利です。
今回でいうとトップページで使用するファイルは以下のディレクトリにまとめています。
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パラメータから自動的に取得出来ます。
翻訳データをページ毎に設定します。
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コンポーネントを翻訳用にカスタマイズ
先ほど用意したuseLang
hookを使って、Linkコンポーネントもカスタマイズします。
これでどの言語ページにいても、意識することなくLinkを設定出来ます。
'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>
)
}
ページ固有の翻訳データを用意する
ja
とen
以下に対になるようにテキストを用意します。
meta
には各ページのmetaをtranslation
にはコンテンツ部分の翻訳を設定します。
i18nextでは、{{count}}
のように書くと、変数を文字列の中に埋め込めるので、動的にテキストが変わる場合に便利です。
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-formとzodResolverを使うと割とスッキリ書けました。
'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 ></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