電通総研 テックブログ

電通総研が運営する技術ブログ

Next.js: Static Exports のi18n(多言語)対応

こんにちは、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で執筆されました