多言語化しなくても、辞書ファイルがあると良く使う文言を共通化できるので便利です。
まだ多言語化は必要なかったけど、ルーティングとセットで語られているので、導入して好みの挙動(/はデフォルト言語、/en等は英語等)になるように調整してみました。
Next.jsは、App RouterでMaterial UI(MUI)導入済みです。
→ Material UI(MUI)でuse clientを外す。Unhandled Runtime Errorの対応
→ React事始め:フレームワークとUIコンポーネントライブラリ選定+トップページ実装
- 選定
- 完成形(next-intlで実装)
- next-intl導入
- ファイル構成変更
- 多言語対応
- 言語ファイル作成
- 言語切り替えselect実装
- Client Components対応
- 今回のコミット内容
選定
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 NextLink from 'next/link'
+ 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 component={NextLink} 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対応