この記事では下記のBlog記事を参考にNext.jsの国際化と、日本語翻訳の追加、TypeScriptの型の追加を0から追跡しています。
動機
既存のアプリにi18nを追加した時、原因不明のエラーが起きたので0から見直してみた。
※エラーの原因とその対処方法はこの記事の一番下を見てください。
使用資料
Next.js +
JavaScript版リポジトリ
TypeScript版(JavaScript版+型)リポジトリ
ライブラリ
i18next 国際化のライブラリ
react-i18next Reactでi18nextを使うための国際化のライブラリ
next-i18next はNext.jsで使う国際化のライブラリ
※App Routerでは↑このライブラリは不要になりました。
元となる記事
↑このBlog記事は、少し修正が必要です。
↑このBlog記事のソースコードが↓下記のリポジトリになります。
JavaScript版リポジトリ
i18next/next-app-dir-i18next-example: Next.js 13/14 app directory feature in combination with i18next
TypeScript版(JavaScript版+型)リポジトリ
i18next/next-app-dir-i18next-example-ts: Next.js 13/14 app directory feature in combination with i18next
調査して完成(動作確認済み)した私のリポジトリ
(後、日本語訳も追加)
masakinihirota/next_i18n_type
※ハンズオン形式でNext.jsをインストールするところから始めます。(写経)
JavaScript版のインストール
Next.js インストール
pnpm dlx create-next-app [アプリ名]
※[アプリ名]はnext_i18n_typeとしました。
ライブラリのインストール
pnpm install i18next react-i18next i18next-resources-to-backend accept-language react-cookie i18next-browser-languagedetector
JavaScript版
約800行
フォルダ構造
src
└── app
└── [lng]
├── second-page
│ └── page.js
├── layout.js
└── page.js
VSCode拡張機能
Ascii Tree Generator - Visual Studio Marketplace
https://marketplace.visualstudio.com/items?itemName=aprilandjan.ascii-tree-generator
Blogなどに貼るTreeを作ります。
#app
##[lng]
###second-page
#####page.js
###layout.js
###page.js
0
削除
src\app\page.tsx
src\app\layout.js
通常のトップページを削除しています。
※項目の数字は参考にしているBlog記事と同じです。
1 Folder structure
国際化した時のトップページを作っています。
mkdir src/app/[lng]/
touch src/app/[lng]/page.js
import Link from 'next/link'
export default function Page({ params: { lng } }) {
return (
<>
<h1>Hi there!</h1>
<Link href={`/${lng}/second-page`}>
second page
</Link>
</>
)
}
mkdir src/app/[lng]/second-page/
touch src/app/[lng]/second-page/page.js
import Link from 'next/link'
export default function Page({ params: { lng } }) {
return (
<>
<h1>Hi from second page!</h1>
<Link href={`/${lng}`}>
back
</Link>
</>
)
}
touch src/app/[lng]/layout.js
import { dir } from 'i18next'
const languages = ['en', 'de']
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>
)
}
動作確認
表示される、リンクも動作する
http://localhost:3000/ja
http://localhost:3000/en
http://localhost:3000/de
http://localhost:3000/aaa
2 Language detection
mkdir src/app/i18n/
touch src/app/i18n/settings.js
export const fallbackLng = 'ja'
export const languages = [fallbackLng, 'en']
編集 src/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>
)
}
touch src/middleware.js
import { NextResponse } from 'next/server'
import acceptLanguage from 'accept-language'
import { fallbackLng, languages, cookieName } from './app/i18n/settings'
acceptLanguage.languages(languages)
export const config = {
// matcher: '/:lng*'
matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)']
}
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
// Redirect if lng in path is not supported
if (
!languages.some(loc => req.nextUrl.pathname.startsWith(`/${loc}`)) &&
!req.nextUrl.pathname.startsWith('/_next')
) {
return NextResponse.redirect(new URL(`/${lng}${req.nextUrl.pathname}`, 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()
}
動作確認
表示される、リンクも動作する
http://localhost:3000/ja
http://localhost:3000/en
404(登録していない言語)
http://localhost:3000/de
http://localhost:3000/aaa
自動遷移
http://localhost:3000/
↓
http://localhost:3000/ja
3 i18n instrumentation
touch src/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
}
}
編集 src/app/i18n/settings.js
export const fallbackLng = 'ja'
export const languages = [fallbackLng, 'en']
export const defaultNS = 'translation'
export const cookieName = 'i18next'
export function getOptions (lng = fallbackLng, ns = defaultNS) {
return {
// debug: true,
supportedLngs: languages,
fallbackLng,
lng,
fallbackNS: defaultNS,
defaultNS,
ns
}
}
翻訳ファイル
src
└── app
└── i18n
└── locales
├── de
│ ├── translation.json
│ └── second-page.json
├── en
│ ├── translation.json
│ └── second-page.json
└── ja
├── translation.json
└── second-page.json
mkdir src/app/i18n/locales/de/
mkdir src/app/i18n/locales/en/
mkdir src/app/i18n/locales/ja/
touch src/app/i18n/locales/de/translation.json
touch src/app/i18n/locales/en/translation.json
touch src/app/i18n/locales/ja/translation.json
touch src/app/i18n/locales/de/second-page.json
touch src/app/i18n/locales/en/second-page.json
touch src/app/i18n/locales/ja/second-page.json
{
"title": "Hallo und willkommen bei VNS.BLUE!",
"to-second-page": "Zur zweiten Seite",
"to-client-page": "Zur clientseitigen Seite"
}
{
"title": "Hello and welcome to VNS.BLUE!",
"to-second-page": "To second page",
"to-client-page": "To client page"
}
{
"title": "こんには、VNS.BLUEへようこそ!",
"to-second-page": "2ページ目へ",
"to-client-page": "クライアントページへ"
}
{
"title": "Hallo von der zweiten Seite!",
"back-to-home": "Zurück zur Seite 1"
}
{
"title": "Hi from second page!",
"back-to-home": "Go back to page 1 "
}
{
"title": "Hi from second page!",
"back-to-home": "1ページに戻る"
}
編集 src/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}/second-page`}>
{t('to-second-page')}
</Link>
</>
)
}
編集 src/app/[lng]/second-page/page.js
import Link from 'next/link'
import { useTranslation } from '../../i18n'
export default async function Page({ params: { lng } }) {
const { t } = await useTranslation(lng, 'second-page')
return (
<>
<h1>{t('title')}</h1>
<Link href={`/${lng}`}>
{t('back-to-home')}
</Link>
</>
)
}
動作確認
Hello
http://localhost:3000/ja
http://localhost:3000/en
デフォルト言語に遷移
http://localhost:3000/
↓
http://localhost:3000/ja
4. Language switcher
mkdir src/app/[lng]/components/Footer/
touch src/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>
)
}
翻訳ファイル
{
"languageSwitcher": "Wechseln von <1>{{lng}}</1> nach: "
}
{
"languageSwitcher": "Switch from <1>{{lng}}</1> to: "
}
{
"languageSwitcher": "変更する <1>{{lng}}</1> から "
}
編集 src/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}/second-page`}>
{t('to-second-page')}
</Link>
<Footer lng={lng}/>
</>
)
}
編集 src/app/[lng]/second-page/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, 'second-page')
return (
<>
<h1>{t('title')}</h1>
<Link href={`/${lng}`}>
{t('back-to-home')}
</Link>
<Footer lng={lng}/>
</>
)
}
5. Client side
touch src/app/i18n/client.js
'use client'
import { useEffect, useState } from 'react'
import i18next from 'i18next'
import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next'
import { useCookies } from 'react-cookie'
import resourcesToBackend from 'i18next-resources-to-backend'
import LanguageDetector from 'i18next-browser-languagedetector'
import { getOptions, languages, cookieName } from './settings'
const runsOnServerSide = typeof window === 'undefined'
i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
.init({
...getOptions(),
lng: undefined, // let detect the language on client side
detection: {
order: ['path', 'htmlTag', 'cookie', 'navigator'],
},
preload: runsOnServerSide ? languages : []
})
export function useTranslation(lng, ns, options) {
const [cookies, setCookie] = useCookies([cookieName])
const ret = useTranslationOrg(ns, options)
const { i18n } = ret
if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
i18n.changeLanguage(lng)
} else {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage)
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (activeLng === i18n.resolvedLanguage) return
setActiveLng(i18n.resolvedLanguage)
}, [activeLng, i18n.resolvedLanguage])
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (!lng || i18n.resolvedLanguage === lng) return
i18n.changeLanguage(lng)
}, [lng, i18n])
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (cookies.i18next === lng) return
setCookie(cookieName, lng, { path: '/' })
}, [lng, cookies.i18next])
}
return ret
}
クライアント側では通常のi18nextシングルトンです。
一度だけ初期化されます。
そして、"通常の"useTranslationフックを利用できます。
言語を渡せるようにラップするだけです。
サーバーサイドの言語検出に合わせるために、
i18next-browser-languagedetector
を利用し、
それに従って設定します。
src
└── app
└── [lng]
└── components
└── Footer
├── client.js
├── FooterBase.js
└── index.js
touch src/app/[lng]/components/Footer/FooterBase.js
import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages } from '@/app/i18n/settings'
export const FooterBase = ({ t, lng }) => {
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>
)
}
編集 src/app/[lng]/components/Footer/index.js
import { useTranslation } from '@/app/i18n'
import { FooterBase } from './FooterBase'
export const Footer = async ({ lng }) => {
const { t } = await useTranslation(lng, 'footer')
return <FooterBase t={t} lng={lng} />
}
touch src/app/[lng]/components/Footer/client.js
'use client'
import { FooterBase } from './FooterBase'
import { useTranslation } from '../../../i18n/client'
export const Footer = ({ lng }) => {
const { t } = useTranslation(lng, 'footer')
return <FooterBase t={t} lng={lng} />
}
mkdir src/app/[lng]/client-page/
touch src/app/[lng]/client-page/page.js
'use client'
import Link from 'next/link'
import { useTranslation } from '@/app/i18n/client'
import { Footer } from '../components/Footer/client'
import { useState } from 'react'
export default function Page({ params: { lng } }) {
const { t } = useTranslation(lng, 'client-page')
const [counter, setCounter] = useState(0)
return (
<>
<h1>{t('title')}</h1>
<p>{t('counter', { count: counter })}</p>
<div>
<button onClick={() => setCounter(Math.max(0, counter - 1))}>-</button>
<button onClick={() => setCounter(Math.min(10, counter + 1))}>+</button>
</div>
<Link href={`/${lng}`}>
<button type="button">
{t('back-to-home')}
</button>
</Link>
<Footer lng={lng} />
</>
)
}
翻訳ファイル
touch src/app/i18n/locales/de/client-page.json
touch src/app/i18n/locales/en/client-page.json
touch src/app/i18n/locales/ja/client-page.json
{
"title": "Client Seite",
"counter_one": "eines ausgewählt",
"counter_other": "{{count}} ausgewählt",
"counter_zero": "keines ausgewählt",
"back-to-home": "Zurück zur Hauptseite"
}
{
"title": "Client page",
"counter_one": "one selected",
"counter_other": "{{count}} selected",
"counter_zero": "none selected",
"back-to-home": "Back to home"
}
{
"title": "クライアントページ",
"counter_one": "1つ選択されました",
"counter_other": "{{count}}個選択されました",
"counter_zero": "選択されていません",
"back-to-home": "ホームに戻る"
}
import Link from 'next/link'
import { useTranslation } from '@/app/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}/second-page`}>
{t('to-second-page')}
</Link>
<br />
<Link href={`/${lng}/client-page`}>
{t('to-client-page')}
</Link>
<Footer lng={lng}/>
</>
)
}
動作確認
これまで調べてきたJavaScript版に型を追加します。
↓これらのリポジトリを参考にしています。
TypeScript版(JavaScript版+型)
i18next/next-app-dir-i18next-example-ts: Next.js 13/14 app directory feature in combination with i18next
型の追加
拡張子の変更と、型を追加したもの
1 Folder structure
2 Language detection
import { dir } from 'i18next'
// const languages = ['en', 'de']
import { languages,fallbackLng } from '@/app/i18n/settings'
import { useTranslation } from '../i18n'
export async function generateStaticParams() {
return languages.map((lng) => ({ lng }))
}
export async function generateMetadata({ params: { lng } }: {
params: {
lng: string;
};
}) {
if (languages.indexOf(lng) < 0) lng = fallbackLng
// eslint-disable-next-line react-hooks/rules-of-hooks
const { t } = await useTranslation(lng)
return {
title: t('title'),
content: 'A playground to explore new Next.js 13/14 app directory features such as nested layouts, instant loading states, streaming, and component level data fetching.'
}
}
export default function RootLayout({
children,
params: {
lng
}
}: {
children: React.ReactNode;
params: {
lng: string;
};
}) {
return (
<html lang={lng} dir={dir(lng)}>
<head />
<body>
{children}
</body>
</html>
)
}
import { NextRequest, NextResponse } from 'next/server'
import acceptLanguage from 'accept-language'
import { fallbackLng, languages, cookieName } from './app/i18n/settings'
acceptLanguage.languages(languages)
export const config = {
// matcher: '/:lng*'
matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)']
}
export function middleware(req: NextRequest) {
if (req.nextUrl.pathname.indexOf('icon') > -1 || req.nextUrl.pathname.indexOf('chrome') > -1) return NextResponse.next()
let lng: string | undefined | null
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
// Redirect if lng in path is not supported
if (
!languages.some(loc => req.nextUrl.pathname.startsWith(`/${loc}`)) &&
!req.nextUrl.pathname.startsWith('/_next')
) {
return NextResponse.redirect(new URL(`/${lng}${req.nextUrl.pathname}`, 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()
}
3 i18n instrumentation
import { createInstance, Namespace, FlatNamespace, KeyPrefix } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { FallbackNs } from 'react-i18next'
import { getOptions } from './settings'
const initI18next = async (lng: string, ns: string | string[]) => {
// on server side we create a new instance for each render, because during compilation everything seems to be executed in parallel
const i18nInstance = createInstance()
await i18nInstance
.use(initReactI18next)
.use(resourcesToBackend((language: string, namespace: string) => import(`./locales/${language}/${namespace}.json`)))
.init(getOptions(lng, ns))
return i18nInstance
}
export async function useTranslation<
Ns extends FlatNamespace,
KPrefix extends KeyPrefix<FallbackNs<Ns>> = undefined
>(
lng: string,
ns?: Ns,
options: { keyPrefix?: KPrefix } = {}
) {
const i18nextInstance = await initI18next(lng, Array.isArray(ns) ? ns as string[] : ns as string)
return {
t: i18nextInstance.getFixedT(lng, ns, options.keyPrefix),
i18n: i18nextInstance
}
}
// export const fallbackLng = 'ja'
// export const languages = [fallbackLng, 'en']
export const fallbackLng = 'ja'
export const languages = [fallbackLng, 'en','de']
export const defaultNS = 'translation'
export const cookieName = 'i18next'
export function getOptions (lng = fallbackLng, ns: string | string[] = defaultNS) {
return {
// debug: true,
supportedLngs: languages,
// preload: languages,
fallbackLng,
lng,
fallbackNS: defaultNS,
defaultNS,
ns,
// backend: {
// projectId: '01b2e5e8-6243-47d1-b36f-963dbb8bcae3'
// }
}
}
翻訳ファイル
src
└── app
└── i18n
└── locales
├── de
│ ├── translation.json
│ └── second-page.json
├── en
│ ├── translation.json
│ └── second-page.json
└── ja
├── translation.json
└── second-page.json
{
"h1": "Ein einfaches Beispiel",
"title": "Hauptseite",
"to-client-page": "Zur client seitigen Seite",
"to-second-page": "Zur zweiten Seite",
"welcome": "Willkommen ZU Next.js 13/14 <1>mit den neuen App-Verzeichnisfunktionen</1> und i18next",
"blog": {
"text": "Schau dir den entsprechenden <1>Blogeintrag</1> dieses Beispiels an.",
"link": "https://locize.com/blog/next-app-dir-i18n/"
}
}
{
"h1": "A simple example",
"title": "Home",
"to-client-page": "To client page",
"to-second-page": "To second page",
"welcome": "Welcome to Next.js 13/14 <1>with the new app directory features</1> and i18next",
"blog": {
"text": "Check out the corresponding <1>blog post</1> describing this example.",
"link": "https://locize.com/blog/next-app-dir-i18n/"
}
}
{
"h1": "シンプルなサンプル",
"title": "こんには、VNS.BLUEへようこそ!",
"to-client-page": "クライアントページへ",
"to-second-page": "2ページ目へ",
"welcome": "Next.js 13/14へようこそ <1>新しいアプリディレクトリ機能</1>とi18next",
"blog": {
"text": "この例を説明した<1>ブログ記事</1>をご覧ください。",
"link": "https://locize.com/blog/next-app-dir-i18n/"
}
}
{
"h1": "Eine zweite Seite, um das Routing zu demonstrieren",
"title": "Hallo von der zweiten Seite!",
"back-to-home": "Zurück zur Seite 1"
}
{
"h1": "A second page, to demonstrate routing",
"title": "Hi from second page!",
"back-to-home": "Go back to page 1 "
}
{
"h1": "A second page, to demonstrate routing",
"title": "Hi from second page!",
"back-to-home": "1ページに戻る"
}
4 Language switcher
翻訳ファイル
{
"description": "Dies ist eine Nicht-Seitenkomponente, die einen eigenen Namespace erfordert",
"helpLocize": "Wenn Sie <1>locize</1> einsetzen, unterstützen Sie direkt die Zukunft von <3>i18next</3>.",
"languageSwitcher": "Wechseln von <1>{{lng}}</1> nach: "
}
{
"description": "This is a non-page component that requires its own namespace",
"helpLocize": "With using <1>locize</1> you directly support the future of <3>i18next</3>.",
"languageSwitcher": "Switch from <1>{{lng}}</1> to: "
}
{
"description": "これは独自の名前空間を必要とする非ページコンポーネントです。", "helpLocize": "<1>locize</1>を使用することで、<3>i18next</3>の未来を直接サポートします。",
"languageSwitcher": "変更する <1>{{lng}}</1> から "
}
touch src\app\[lng]\components\Header.tsx
export const Header = ({ heading }: { heading: string }) => (
<>
<h2>
Next.js 13 <small>(app directory)</small> - i18next
<hr />
</h2>
<h1>{heading}</h1>
<a className="github" href="//github.com/i18next/i18next">
<i className="typcn typcn-social-github-circular" />
</a>
</>
)
import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages, fallbackLng } from '../i18n/settings'
import { useTranslation } from '../i18n'
import { Header } from './components/Header'
import { Footer } from './components/Footer'
export default async function Page({ params: { lng } }: {
params: {
lng: string;
};
}) {
if (languages.indexOf(lng) < 0) lng = fallbackLng
const { t } = await useTranslation(lng)
return (
<>
<main>
<Header heading={t('h1')} />
<h2>
<Trans t={t} i18nKey="welcome">
Welcome to Next.js v13 <small>appDir</small> and i18next
</Trans>
</h2>
<div style={{ width: '100%' }}>
<p>
<Trans t={t} i18nKey="blog.text">
Check out the corresponding <a href={t('blog.link')}>blog post</a> describing this example.
</Trans>
</p>
<a href={t('blog.link')}>
<img
style={{ width: '50%' }}
alt="next 13 blog post"
src="https://locize.com/blog/next-app-dir-i18n/next-app-dir-i18n.jpg"
/>
</a>
</div>
<hr style={{ marginTop: 20, width: '90%' }} />
<div>
<Link href={`/${lng}/second-page`}>
<button type="button">{t('to-second-page')}</button>
</Link>
<Link href={`/${lng}/client-page`}>
<button type="button">{t('to-client-page')}</button>
</Link>
</div>
</main>
<Footer lng={lng}/>
</>
)
}
touch src\app\[lng]\second-page\layout.tsx
import { languages, fallbackLng } from '../../i18n/settings'
import { useTranslation } from '../../i18n'
export async function generateStaticParams() {
return languages.map((lng) => ({ lng }))
}
export async function generateMetadata({ params: { lng } }: {
params: {
lng: string;
};
}) {
if (languages.indexOf(lng) < 0) lng = fallbackLng
// eslint-disable-next-line react-hooks/rules-of-hooks
const { t } = await useTranslation(lng, 'second-page')
return {
title: t('title')
}
}
export default function Layout({ children }: {
children: React.ReactNode;
params: {
lng: string;
};
}) {
return children
}
import Link from 'next/link'
import { useTranslation } from '../../i18n'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
export default async function Page({ params: { lng } }: {
params: {
lng: string;
};
}) {
const { t } = await useTranslation(lng, 'second-page')
return (
<>
<main>
<Header
heading={t('h1')}
/>
<Link href={`/${lng}`}>
<button type="button">
{t('back-to-home')}
</button>
</Link>
</main>
<Footer lng={lng} path="/second-page" />
</>
)
}
5 Client side
'use client'
import { useEffect, useState } from 'react'
import i18next, { FlatNamespace, KeyPrefix } from 'i18next'
import { initReactI18next, useTranslation as useTranslationOrg, UseTranslationOptions, UseTranslationResponse, FallbackNs } from 'react-i18next'
import { useCookies } from 'react-cookie'
import resourcesToBackend from 'i18next-resources-to-backend'
// import LocizeBackend from 'i18next-locize-backend'
import LanguageDetector from 'i18next-browser-languagedetector'
import { getOptions, languages, cookieName } from './settings'
const runsOnServerSide = typeof window === 'undefined'
// on client side the normal singleton is ok
i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(resourcesToBackend((language: string, namespace: string) => import(`./locales/${language}/${namespace}.json`)))
// .use(LocizeBackend) // locize backend could be used on client side, but prefer to keep it in sync with server side
.init({
...getOptions(),
lng: undefined, // let detect the language on client side
detection: {
order: ['path', 'htmlTag', 'cookie', 'navigator'],
},
preload: runsOnServerSide ? languages : []
})
export function useTranslation<
Ns extends FlatNamespace,
KPrefix extends KeyPrefix<FallbackNs<Ns>> = undefined
>(
lng: string,
ns?: Ns,
options?: UseTranslationOptions<KPrefix>,
): UseTranslationResponse<FallbackNs<Ns>, KPrefix> {
const [cookies, setCookie] = useCookies([cookieName])
const ret = useTranslationOrg(ns, options)
const { i18n } = ret
if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
i18n.changeLanguage(lng)
} else {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage)
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (activeLng === i18n.resolvedLanguage) return
setActiveLng(i18n.resolvedLanguage)
}, [activeLng, i18n.resolvedLanguage])
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (!lng || i18n.resolvedLanguage === lng) return
i18n.changeLanguage(lng)
}, [lng, i18n])
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (cookies.i18next === lng) return
setCookie(cookieName, lng, { path: '/' })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lng, cookies.i18next])
}
return ret
}
src
└── app
└── [lng]
└── components
└── Footer
├── client.tsx
├── FooterBase.tsx
└── index.tsx
import { i18n } from 'i18next'
import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages } from '../../../i18n/settings'
export const FooterBase = ({ i18n, lng, path = '' }: { i18n: i18n, lng: string, path?: string }) => {
const t = i18n.getFixedT(lng, 'footer')
return (
<footer>
<Trans i18nKey="languageSwitcher" t={t}>
{/* @ts-expect-error Trans interpolation */}
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}${path}`}>
{l}
</Link>
</span>
)
})}
<p>{t('description')}</p>
<p
style={{
fontSize: 'smaller',
fontStyle: 'italic',
marginTop: 20,
}}
>
<Trans i18nKey="helpLocize" t={t}>
With using
<a href="https://locize.com" target="_new">
locize
</a>
you directly support the future of
<a href="https://www.i18next.com" target="_new">
i18next
</a>
.
</Trans>
</p>
</footer>
)
}
import { useTranslation } from '../../../i18n'
import { FooterBase } from './FooterBase'
export const Footer = async ({ lng, path }: {
lng: string;
path?: string;
}) => {
const { t, i18n } = await useTranslation(lng, 'footer')
return <FooterBase i18n={i18n} lng={lng} path={path} />
}
'use client'
import { FooterBase } from './FooterBase'
import { useTranslation } from '../../../i18n/client'
// import { useParams } from 'next/navigation'
export function Footer({ lng, path }: {
lng: string;
path: string;
}) {
const { i18n } = useTranslation(lng, 'footer')
return <FooterBase i18n={i18n} lng={lng} path={path} />
}
// if you like to avoid prop drilling, you can do so with useParams()
// export function Footer({ path }: {
// path: string;
// }) {
// const params = useParams<{ lng: string; }>()
// const { i18n } = useTranslation(params.lng, 'footer')
// return <FooterBase i18n={i18n} lng={params.lng} path={path} />
// }
touch src\app\[lng]\client-page\layout.tsx
import { languages, fallbackLng } from '../../i18n/settings'
import { useTranslation } from '../../i18n'
export async function generateStaticParams() {
return languages.map((lng) => ({ lng }))
}
export async function generateMetadata({ params: { lng } }: {
params: {
lng: string;
};
}) {
if (languages.indexOf(lng) < 0) lng = fallbackLng
// eslint-disable-next-line react-hooks/rules-of-hooks
const { t } = await useTranslation(lng, 'client-page')
return {
title: t('title')
}
}
export default function Layout({ children }: {
children: React.ReactNode;
params: {
lng: string;
};
}) {
return children
}
'use client'
import Link from 'next/link'
import { useTranslation } from '../../i18n/client'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer/client'
import { useState } from 'react'
export default function Page({ params: { lng } }: {
params: {
lng: string;
};
}) {
const { t } = useTranslation(lng, 'client-page')
const [counter, setCounter] = useState(0)
return (
<>
<main>
<Header heading={t('h1')} />
<p>{t('counter', { count: counter })}</p>
<div>
<button onClick={() => setCounter(Math.max(0, counter - 1))}>-</button>
<button onClick={() => setCounter(Math.min(10, counter + 1))}>+</button>
</div>
<Link href={`/${lng}/second-client-page`}>
{t('to-second-client-page')}
</Link>
<Link href={`/${lng}`}>
<button type="button">
{t('back-to-home')}
</button>
</Link>
</main>
<Footer lng={lng} path="/client-page" />
</>
)
}
翻訳ファイル
touch src/app/i18n/locales/de/client-page.json
touch src/app/i18n/locales/en/client-page.json
touch src/app/i18n/locales/ja/client-page.json
{
"back-to-home": "Zurück zur Hauptseite",
"counter_one": "eines ausgewählt",
"counter_other": "{{count}} ausgewählt",
"counter_zero": "keines ausgewählt",
"h1": "Eine client Seite, um das client-seitige i18n zu demonstrieren",
"title": "Client Seite",
"to-second-client-page": "zur zweiten client Seite"
}
{
"back-to-home": "Back to home",
"counter_one": "one selected",
"counter_other": "{{count}} selected",
"counter_zero": "none selected",
"h1": "A client page, to demonstrate client side i18n",
"title": "Client page",
"to-second-client-page": "to second client page"
}
{
"back-to-home": "ホームに戻る",
"counter_one": "1つ選択されました",
"counter_other": "{{count}}個選択されました",
"counter_zero": "選択されていません",
"h1": "クライアント・サイドの国際化を実証するためのクライアント・ページ",
"title": "クライアントページ",
"to-second-client-page": "2番目のクライアントページへ"
}
終わりに
この記事は、あるNext.jsのスターターをi18n(国際化)しようとしたところ
言語変換ができなかったので0から調べてみました。
通常のNext.js
0からインストールしたNext.jsを国際化した場合
言語変換ができた
スターターを国際化してみた結果。
vercel/nextjs-subscription-payments: Clone, deploy, and fully customize a SaaS subscription application with Next.js.
このリポジトリを使って、i18next
でi18n化したところ、言語変換ができなかった。
調査の結果 原因が判明
"scripts": {
"dev": "next dev --turbo",
↑この --turbo
オプションを↓削除する。
"scripts": {
"dev": "next dev",
スターターのpackage.jsonに --turbo
オプションがつているのが原因だった。
これだけで言語変換がうまくいくようになった。