こんにちは、XI本部プロダクトイノベーションセンターの瀧川亮弘です。
現在、Next.jsのStatic Exportsにより、Webサイトの構築を行っています。
本記事ではStatic Exportsのi18n(多言語)対応の実装方法を紹介します。
Static ExportsはNext.jsのアプリケーションを静的コンテンツ(HTML/JavaScript/CSS/Image)としてエクスポートできる機能です。
Nodeサーバーが不要なため、S3などで簡単にホスティングできます。
React/Next.jsのコンポーネントベース指向やルーティング機能などの恩恵を享受しつつ、インフラの運用コストは最小化できます。
リクエストに対して、静的なHTMLを返すだけなのでパフォーマンスもよいです。
i18n対応については公式サイトで説明がありますが、説明が簡略的で別途検討することが多かったため、本記事に実装を記載します。
https://nextjs.org/docs/app/building-your-application/routing/internationalization
ライブラリの利用も考えましたが、自身で実装してもそれほど複雑ではなかったため、独自に実装することとしました。
例えばこちらのライブラリでは、タイムゾーンや単位などのi18n対応も可能なので、要件に応じてライブラリを利用するとよいでしょう。
https://next-intl-docs.vercel.app/
目標(ビルド後のイメージ)
ビルドすると以下のようなoutフォルダが生成されるように実装します。
en, jaといったロケールごとのフォルダが生成されます。
例えば、enフォルダ配下のindex.htmlは英語の文言が適用されます。
out ├─en │ ├─hello │ │ └─index.html │ └─index.html ├─ja │ ├─hello │ │ └─index.html │ └─index.html └─index.html
outフォルダ配下をそのままサーバーにデプロイすれば、静的サイトを配信できます。
画面の役割とURLの関係です。
・ホームページ(英語版):https://[ドメイン]/en/index.html
・ハローページ(日本語版):https://[ドメイン]/ja/hello/index.html
・ダミーページ(アクセスされた場合、いずれかの言語のホームページに強制遷移する):https://[ドメイン]/index.html
実装方法
Next.jsのフォルダ構成は自由度が高いため、いくつか流派がありますが、今回はこのようなフォルダ構成とします。
├─next.config.mjs ├─app │ ├─(root) │ │ ├─layout.tsx │ │ └─page.tsx │ └─[locale] │ ├─hello │ │ └─page.tsx │ ├─layout.tsx │ └─page.tsx ├─constants │ ├─messages │ │ ├─ja.ts │ │ └─en.ts │ └─i18n.ts ├─contexts │ └─i18nContext.tsx ├─hooks │ └─useI18nRouter.ts └─types └─i18n.ts
順に各ファイルの役割を説明します。
まず、Static Exportsを有効化するため、設定ファイルにオプションoutput: 'export’
trailingSlash: true
を指定します。
next.config.mjs
const nextConfig = { output: 'export’, trailingSlash: true, } module.exports = nextConfig
まずは、画面に表示する文言などをロケールごとに定義します。
サポート対象とするロケールに合わせて、en.tsやja.tsを作成します。
constants/messages/en.ts
import { Messages } from '@/types/i18n'; const messages: Messages = { hello: { title: 'english hello page', greeting: (name: string) => `hello!,${name}`; } // 省略 }
constants/messages/ja.ts
import { Messages } from '@/types/i18n'; const messages: Messages = { hello: { title: '日本語ハローページ', greeting: (name: string) => `こんにちは!,${name}`; } // 省略 }
ロケールごとに定義したメッセージは、messagesMap
にまとめておきます。
locales
は対応するロケールの配列です。
defaultLocale
はデフォルトで利用されるロケールです。
constants/i18n.ts
import { Messages, Locale } from '@/types/i18n'; import enMessages from './messages/en'; import jaMessages from './messages/ja'; export const locales: Locale[] = ['en','ja']; export const defaultLocale: Locale = 'en'; export const messagesMap: { [key in Locale]: Messages } = { en: enMessages, ja: jaMessages, };
型も定義します。
types/i18n.ts
export type Locale = 'en' | 'ja'; export type Messages = { hello: { title: string; greeting: (name: string) => string; } // 省略 };
配下のコンポーネントにメッセージを提供する、I18nProviderコンポーネントを定義します。
配下の各コンポーネントでは、useI18nContextを実行することで、メッセージを取得できます。
contexts/i18nContext.tsx
'use client'; import { createContext, useContext } from 'react'; import { Locale, Messages } from '@/types/i18n'; import { messagesMap } from '@/constants/i18n'; const I18nContext = createContext< | { locale: Locale; messages: Messages; } | undefined >(undefined); export const useI18nContext = () => { const context = useContext(I18nContext); if (!context) { throw new Error('Failed to retrieve I18nContext.'); } return context; }; export const I18nProvider = ({ children, locale, }: { children: React.ReactNode; locale: Locale; }) => { const messages = messagesMap[locale]; return ( <I18nContext.Provider value={{ locale, messages }}> {children} </I18nContext.Provider> ); };
ロケール切り替え用の2つの関数を用意します。
switchLocale
は、任意のロケールに切り替える関数です。appendBrowserLocale
は、ブラウザの言語設定に応じてロケールを切り替える関数です。
サポートしていない場合はdefaultLocale
のロケールに切り替えます。
hooks/useI18nRouter.ts
import { useRouter, usePathname } from 'next/navigation'; import { Locale } from '@/types/i18n'; import { locales, defaultLocale } from '@/constants/i18n'; export const useI18nRouter = () => { const router = useRouter(); const pathname = usePathname(); // URLのロケールを変更する const switchLocale = (locale: Locale) => { const newPath = pathname.replace(/^\/[^\/]+/, `/${locale}`); router.push(newPath); }; // URLにブラウザ設定(またはデフォルト)のロケールを付与する const appendBrowserLocale = () => { const browserLocale = window.navigator.language; const locale = locales.find((locale) => browserLocale.startsWith(locale)) || defaultLocale; const newPath = pathname.replace(/^\//, `/${locale}/`); router.push(newPath); }; return { switchLocale, appendBrowserLocale }; };
ここからappフォルダ配下にレイアウトやページを追加します。
app/[locale]フォルダを作成します。
i18n対応が必要なページは全てこの[locale]フォルダ配下に配置します。
generateStaticParams
の戻り値として、ロケール一覧を返すことで、ビルド時に各ロケールごとのHTMLが生成されます。
LocaleLayout
の引数でロケールを受け取り、I18nProvider
コンポーネントのpropsに渡します。
これにより、[locale]配下の各コンポーネントでメッセージが取得できるようになります。
``
app/[locale]/layout.tsx
import { I18nProvider } from '@/contexts/i18nContext'; import { locales } from '@/constants/i18n'; import { Locale } from '@/types/i18n'; import '@/styles/main.scss'; export function generateStaticParams() { return locales.map((locale) => ({ locale })); } export default function LocaleLayout({ children, params, }: { children: React.ReactNode; params: { locale: Locale; }; }) { return ( <html lang={params.locale}> <I18nProvider locale={params.locale}> {children} </I18nProvider> </html> ); }
各ページでuseI18nContext
を実行して、メッセージを取得します。
app/hello/page.tsx
'use client'; import { useI18nContext } from '@/contexts/i18nContext'; export default function Hello() { const { messages } = useI18nContext(); return ( <h1>{messages.title}</h1> // 省略 ); }
app/(root)配下にダミーのレイアウトとページを作成します。
ルートのパスにアクセスしてきた場合、app/[locale]/page.tsx の画面に強制的に遷移させます。
appendBrowserLocale
を実行し、ブラウザの言語設定に応じた[locale]を指定します。
app/(root)/layout.tsx
import { ReactNode } from 'react'; export default function RootLayout({ children }: { children: ReactNode }) { return ( <html> <body>{children}</body> </html> ); }
app/(root)/page.tsx
'use client'; import { useEffect } from 'react'; import { useI18nRouter } from '@/hooks/useI18nRouter'; export default function RootPage() { const { appendBrowserLocale } = useI18nRouter(); useEffect(() => { appendBrowserLocale(); }); }
終わりに
最後までご覧いただきありがとうございます。
お役に立てたら光栄です。
参考
https://qiita.com/koshitake2m2/items/dacfdafb833344bada4d
執筆:@takigawa.akihiro、レビュー:@miyazawa.hibiki
(Shodoで執筆されました)