多言語化しなくても、辞書ファイルがあると良く使う文言を共通化できるので便利です。
まだ多言語化は必要なかったけど、ルーティングとセットで語られているので、導入して好みの挙動(/はデフォルト言語、/en等は英語等)になるように調整してみました。
Next.jsは、App RouterでMaterial UI(MUI)導入済みです。
Material UI(MUI)でuse clientを外す。Unhandled Runtime Errorの対応
React事始め:フレームワークとUIコンポーネントライブラリ選定+トップページ実装

選定

Next.jsの公式に説明が書いてありますが、まだ構造を理解していないので分かり難かった。
Routing: Internationalization | Next.js

最後のResourcesのリンクが主な選択肢です。
Minimal i18n routing and translations ← 標準機能と自前で頑張る系
next-intl ← 公式以外の情報も多い
next-international ← 公式以外の情報が少なかった
next-i18n-router ← i18next(Server側)とreact-i18next(Client側)をセットで使う

Minimal i18n routing and translationsをcloneして試してみましたが、好みの挙動ではなかった。(/で言語を判定して、/enにリダイレクト)
コード直せば行けるかもですが、ここは頑張りたいポイントではないのと、保守対象増えるのも良くないので、別の選択肢へ。
next-intlは、設定を変えれば、好みの挙動(/はデフォルト言語、/en等は英語等)にできたので、こちらで作り込んでみました。

完成形(next-intlで実装)

Cookieを使って、初回アクセスした時の言語・選択した言語が保持される仕様。
リンク先は言語のパスを考慮しなくても、この仕組みで必要に応じてリダイレクトされました。

next-intl導入

公式のドキュメントとサンプルを参考に、可読性を考慮して一部直しています。
要件1:英語以外だったら/で日本語表示。英語だったら/enにリダイレクトして表示
要件2:1言語に簡単に戻せる ←最初から多言語でリリースしないので

Next.js App Router Internationalization (i18n) – Internationalization (i18n) for Next.js
next-intl/examples/example-app-router at main · amannn/next-intl · GitHub

% npm install next-intl

next.config.js

+ // @ts-check
+ const withNextIntl = require('next-intl/plugin')()
+
/** @type {import('next').NextConfig} */
const nextConfig = {}

- module.exports = nextConfig
+ module.exports = withNextIntl(nextConfig)

src/config.ts

‘/pathnames’は不要そうなので、コメントアウトしています。
localePrefixがalwaysだと、常にリダイレクト。as-neededなら要件1(上記)を満たせます。
localesを1つにすれば、要件2(上記)も満たせます。

import { Pathnames } from 'next-intl/navigation'

export const locales = ['en', 'ja'] as const

export const pathnames = {
  '/': '/'
  // '/pathnames': {
  //   en: '/pathnames',
  //   de: '/pfadnamen'
  // }
} satisfies Pathnames

// Use the default: `always`
export const localePrefix = 'as-needed' // https://next-intl-docs.vercel.app/docs/routing/middleware#locale-prefix

export type AppPathnames = keyof typeof pathnames

src/middleware.ts

メモ:'/(en|ja)/:path*'をsrc/config.tsのlocalesで作成するように直したら、動かなくなったので、ここの共通化は断念。

import createMiddleware from 'next-intl/middleware'
import { locales, pathnames, localePrefix } from '@/config'

export default createMiddleware({
  defaultLocale: 'ja',
  locales,
  pathnames,
  localePrefix
})

export const config = {
  matcher: [
    // Enable a redirect to a matching locale at the root
    '/',

    // Set a cookie to remember the previous locale for
    // all requests that have a locale prefix
    '/(en|ja)/:path*',

    // Enable redirects that add missing locales
    // (e.g. `/pathnames` -> `/en/pathnames`)
    '/((?!_next|_vercel|.*\\..*).*)'
  ]
}

src/i18n.ts

これは呼び出しを定義してないけど、デフォルトで勝手に読まれている感じかな。

import { notFound } from 'next/navigation'
import { getRequestConfig } from 'next-intl/server'
import { locales } from '@/config'

export default getRequestConfig(async ({locale}) => {
  // Validate that the incoming `locale` parameter is valid
  if (!locales.includes(locale as any)) notFound()

  return {
    messages: (
      await (locale === 'en'
        ? // When using Turbopack, this will enable HMR for `en`
          import('../messages/en.json')
        : import(`../messages/${locale}.json`))
    ).default
  }
})

ファイル構成変更

src/appのファイルをsrc/app/[locale]に移動します。

src/app/layout.tsx → src/app/[locale]/layout.tsx
src/app/page.tsx → src/app/[locale]/page.tsx
src/app/components → src/app/[locale]/components

src/app/layout.tsx

これがないとレイアウトが表示されない。
逆にsrc/app/page.tsxはなくても表示される。
公式サンプルは/enにリダイレクトするように書いてありましたが、今回は不要そう。

export default function Layout({ children }: { children: React.ReactNode }) {
  return children
}

下記を作成(後でも良い)

messages/ja.json
messages/en.json
{
}

多言語対応

.env

サーバー環境(NODE_ENVではない)毎に環境名を表示したいので、これで切り替えるようにします。

SERVER_ENV=development  # or test or staging or production

src/app/[locale]/layout.tsx

paramsのlocaleに’ja’等の文字が入っています。useLocale()でも取得できます。
htmlのlangにも設定するようにしました。

metadata内では、useTranslations()は使えないので、getTranslations()を使っています。

- import type { Metadata } from 'next'
+ import { useTranslations } from 'next-intl'
+ import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'
import { Inter } from 'next/font/google'
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter'
import { ThemeProvider } from '@mui/material/styles'
import { theme } from '@/theme'
import CssBaseline from '@mui/material/CssBaseline'

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

- export const metadata: Metadata = {
-   title: 'NextAppOrigin【開発環境】',
-   description: 'Next.js(React/Material UI)のベースアプリケーションです。(サービス概要に差し替え)'
- }
+ export const metadata = (async () => {
+   const t = await getTranslations()
+
+   return {
+     title: {
+       template: `%s - ${t('app_name')}${t(`env_name.${process.env.SERVER_ENV || 'production'}`)}`
+     },
+     description: t('サービス説明')
+   }
+ })

- export default function RootLayout({
-   children,
- }: {
-   children: React.ReactNode
- }) {
+ export default function Layout({ children, params: { locale } }: { children: React.ReactNode, params: { locale: string } }) {
+   // Enable static rendering
+   unstable_setRequestLocale(locale)
+   const t = useTranslations()
+
  return (
-     <html>
+     <html lang={locale}>
        <body className={inter.className}>
          <AppRouterCacheProvider>
            <ThemeProvider theme={theme}>
              <CssBaseline />
-             <main>{children}</main>
+             <div>
+               <Link href="/">{`${t('app_name')}${t('sub_title')}${t(`env_name.${process.env.SERVER_ENV || 'production'}`)}`}</Link>
+             </div>
+             <hr />
+             <main>{children}</main>
+             <hr />
+             <div>
+               Copyright © <Link href={t('my_url')} target="_blank" rel="noopener noreferrer">{t('my_name')}</Link> All Rights Reserved.
+             </div>
            </ThemeProvider>
          </AppRouterCacheProvider>
        </body>
      </html>
  )
}

src/app/[locale]/page.tsx

+ import { useTranslations } from 'next-intl'
+ import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'

<省略>

+ export const metadata = (async () => {
+   const t = await getTranslations()
+
+   return {
+     title: {
+       absolute: `${t('app_name')}${t(`env_name.${process.env.SERVER_ENV || 'production'}`)}`
+     }
+   }
+ })

- export default function Page() {
+ export default function Page({ params: { locale } }: { params: { locale: string } }) {
+   // Enable static rendering
+   unstable_setRequestLocale(locale)
+   const t = useTranslations()
    const repositoryURL = 'https://dev.azure.com/nightonly/_git/next-app-origin'

  return (
<省略>
-                 Next.js(React/Material UI)のベースアプリケーションです。(サービス概要に差し替え)
+                 {t('サービス概要')}
                </Typography>
                <Typography variant="body2" color="text.secondary">
-                 サービスを迅速に立ち上げられるように、よく使う機能を予め開発しています。(サービス説明に差し替え)<br />
+                 {t('サービス説明')}<br />
-                 リポジトリ: <Link href={repositoryURL} target="_blank" rel="noopener noreferrer">{repositoryURL}</Link>
+                 {t('リポジトリ')}: <Link href={repositoryURL} target="_blank" rel="noopener noreferrer">{repositoryURL}</Link>
<省略>

src/app/[locale]/components/Infomations.tsx

+ import { useTranslations } from 'next-intl'

<省略>

- export default function App() {
+ export default function Infomations() {
+   const t = useTranslations()

<省略>

-           大切なお知らせ
+           {t('大切なお知らせ')}

src/app/[locale]/components/SignUp.tsx

上記と同じ要領なので省略

src/app/[locale]/users/sign_up/page.tsx

リンク先のページを仮で作成します。

import { useTranslations } from 'next-intl'
import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'

export const metadata = (async () => {
  const t = await getTranslations()

  return {
    title: t('アカウント登録')
  }
})

export default function Page({ params: { locale } }: { params: { locale: string } }) {
  // Enable static rendering
  unstable_setRequestLocale(locale)
  const t = useTranslations()

  return (
    <>
      TODO: {t('アカウント登録')}
    </>
  )
}

src/app/[locale]/users/sign_in/page.tsx

上記と同じ要領なので省略

src/app/[locale]/development/color/page.tsx

上記と同じ要領なので省略

言語ファイル作成

上記で使用したキーを定義して行きます。
長すぎるのはダメですが、キーに日本語も使えます。
コードとメッセージが視覚的に区別できるので、解りやすいと思います。

messages/ja.json

{
  "app_name": "NextAppOrigin",
  "sub_title": ": ベースアプリケーション",
  "env_name": {
    "development": "【開発環境】",
    "test": "【テスト環境】",
    "staging": "【STG環境】",
    "production": ""
  },
  "my_name": "My name",
  "my_url": "https://example.com",
  "サービス概要": "Next.js(React/Material UI)のベースアプリケーションです。(サービス概要に差し替え)",
  "サービス説明": "サービスを迅速に立ち上げられるように、よく使う機能を予め開発しています。(サービス説明に差し替え)",
  "リポジトリ": "リポジトリ",
  "development": "development",
  "テーマカラー確認": "テーマカラー確認",
  "大切なお知らせ": "大切なお知らせ",
  "アカウント登録": "アカウント登録",
  "無料で始める": "無料で始める",
  "ログイン": "ログイン"
}

messages/en.json

{
  "app_name": "NextAppOrigin",
  "sub_title": ": base application",
  "env_name": {
    "development": " [Development]",
    "test": " [Test]",
    "staging": " [Staging]",
    "production": ""
  },
  "my_name": "My name",
  "my_url": "https://example.com",
  "サービス概要": "This is an Next.js(React/Material UI) base application. (Replaced with service overview)",
  "サービス説明": "We have developed frequently used functions in advance so that you can quickly launch your service. (Replaced with service description)",
  "リポジトリ": "Repository",
  "development": "development",
  "テーマカラー確認": "Confirm theme color",
  "大切なお知らせ": "Important notice",
  "アカウント登録": "Account registration",
  "無料で始める": "Get started for free",
  "ログイン": "Login"
}

言語切り替えselect実装

src/app/[locale]/layout.tsx

<省略>
+ import LocaleSwitcher from '@/components/LocaleSwitcher'

<省略>
+           <LocaleSwitcher />
<省略>

src/components/LocaleSwitcher.tsx

1言語だったらreturnして、非表示にしています。

import { useLocale } from 'next-intl'
import { localeNames } from '@/config'
import LocaleSwitcherSelect from './LocaleSwitcherSelect'

export default function LocaleSwitcher() {
  const locale = useLocale()
  const localeObject = Object.entries(localeNames)
  if (localeObject.length <= 1) { return }

  return (
    <LocaleSwitcherSelect defaultValue={locale}>
      {localeObject.map((item) => (
        <option key={item[0]} value={item[0]}>
          {item[1]}
        </option>
      ))}
    </LocaleSwitcherSelect>
  )
}

src/config.ts

import { Pathnames } from 'next-intl/navigation'

- export const locales = ['en', 'ja'] as const
+ export const localeNames = {
+   en: 'English',
+   ja: '日本語'
+ } as const
+ export const locales = Object.keys(localeNames)

<省略>

src/components/LocaleSwitcherSelect.tsx

'use client'
import { ReactNode, useTransition, ChangeEvent } from 'react'
import { useRouter, usePathname } from '@/navigation'

export default function LocaleSwitcherSelect({ children, defaultValue }: { children: ReactNode, defaultValue: string }) {
  const [isPending, startTransition] = useTransition()
  const router = useRouter()
  const pathname = usePathname()

  function onSelectChange(event: ChangeEvent<HTMLSelectElement>) {
    startTransition(() => {
      router.replace(pathname, {locale: event.target.value})
    })
  }

  return (
    <select
      defaultValue={defaultValue}
      disabled={isPending}
      onChange={onSelectChange}
    >
      {children}
    </select>
  )
}

src/navigation.ts

import { createLocalizedPathnamesNavigation } from 'next-intl/navigation'
import { locales, pathnames, localePrefix } from '@/config'

export const {Link, redirect, usePathname, useRouter} =
  createLocalizedPathnamesNavigation({
    locales,
    pathnames,
    localePrefix
  })

Client Components対応

Client ComponentsでuseTranslations()を使おうとするとエラーになります。

app/[locale]/components/Infomations.tsx

+'use client'
Unhandled Runtime Error
Error: Failed to call `useTranslations` because the context from `NextIntlClientProvider` was not found.

src/app/[locale]/layout.tsx

Internationalization of Server & Client Components – Internationalization (i18n) for Next.js

- import { useTranslations } from 'next-intl'
+ import { useTranslations, NextIntlClientProvider, useMessages } from 'next-intl'

+  // Receive messages provided in `i18n.ts`
+  const messages = useMessages()

  return (
    <html lang={locale}>
      <body className={inter.className}>
        <AppRouterCacheProvider>
+         <NextIntlClientProvider locale={locale} messages={messages}>
            <ThemeProvider theme={theme}>
              <CssBaseline />

              <main>{children}</main>

            </ThemeProvider>
+         </NextIntlClientProvider>
        </AppRouterCacheProvider>
      </body>
    </html>
  )

これでエラーなく、動くようになりました。
ちなみに、NextIntlClientProviderをThemeProviderの中に入れるとWarningが出ます。

Warning: Failed prop type: Invalid prop `children` supplied to `ThemeProvider`, expected a ReactNode.
    at ThemeProvider (webpack-internal:///(ssr)/./node_modules/@mui/system/esm/ThemeProvider/ThemeProvider.js:58:13)
    at ThemeProvider (webpack-internal:///(ssr)/./node_modules/@mui/material/styles/ThemeProvider.js:26:18)
    at Lazy
    at AppRouterCacheProvider (webpack-internal:///(ssr)/./node_modules/@mui/material-nextjs/v13-appRouter/appRouterV13.js:22:13)

今回のコミット内容

next-intl導入、多言語化
言語切り替えselect実装
next-intlのClient Components対応

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です