🌐

Next + typescript で i18n対応し、SSR、SSGを行うexample

2021/07/19に公開

Next + typescript で i18n 対応し、SSR、SSG を行う example

本項では、Next を使用して i18n 対応(SSR, SSG)を方法を解説します。

記事の要約

  • next-i18nextを使うとi18nextを使用して、SSR、SSG を比較的手軽に行う事ができる
  • react-i18next.d.tsを使用して、typescript対応を行うと使い勝手が上がる
  • i18n 対応時はnext-seoが便利
    • i18n 対応していない場合も便利
    • i18n 対応時はalternatesの設定をきちんとする
  • 実装例はhttps://github.com/SiroSuzume/typed-i18next-exampleのリポジトリにおいた
  • storybook 対応は別途作成中

使用するライブラリについて

以下、本項で使用する主要なライブラリを記載します。

  • Next
    • React 用フレームワーク
  • i18next
    • 名前が似ているので混乱するかもしれないですが、i18next と Next は直接的な関係はありません。
    • React 以外の環境でも動作するライブラリです。
  • react-i18next
    • i18next を React で使いやすくするためのライブラリです。hooks ライクな翻訳用関数コンポーネントを提供します。
  • next-i18next
    • 更に名前がややこしいですが、react-i18next を、Next の環境で使用するためのライブラリです。
    • 翻訳用 JSON を動的に読み込み、SSR、SSG 時に i18nextで使用できるようにしてくれます。
  • next-seo
    • SEO 情報を作成する際に便利なライブラリ

i18next の概要説明

i18next は key を索引、value を翻訳文とした辞書を管理し、索引に対応する翻訳文を返却する Javascript 用ライブラリです。辞書は言語とネームスペースで区切られて管理されています。フォールバックに使用する言語、ネームスペースを指定し、未翻訳の文章に対応するなどの柔軟な使用が可能です。

詳しくは公式サイトを参照してください。i18next.com

今回の記事では競走馬の名前を例に解説します。

翻訳用の JSON ファイルの例

// public/locales/ja/horse-names.json
{
  "ステイゴールド": "ステイゴールド",
  "ゴールドシップ": "ゴールドシップ"
}
// public/locales/en/horse-names.json
// ゴールドシップの翻訳文が用意されていない状態
{
  "ステイゴールド": "Stay Gold"
}
// public/locales/zh/horse-names.json
{
  "ステイゴールド": "黄金旅程",
  "ゴールドシップ": "黄金船"
}
  • 上記の翻訳リソースを使用
  • ユーザーの言語がen
  • フォールバックの設定をjaにしている場合

の条件ではでi18nextによる翻訳を行うと
ステイゴールドの翻訳をした場合Stay Gold
ゴールドシップを翻訳すると、enに対応した翻訳文がないため、jaの翻訳文が優先され、結果としてゴールドシップが返却されます

react-i18nextの役割

react-i18next は、上記の処理を React 上で扱いやすくするためのライブラリです。下記の例のように、useTranslationから返却されたtを使用して翻訳を行います。

// ネームスペースにhorse-namesを使用
const { t } = useTranslation('horse-names');
return (
  <>
    <div>{t('ステイゴールド')}</div>
    <div>{t('ゴールドシップ')}</div>
  </>
);

next-i18next を有効にする

next-i18nextは、Nexti18n ルーティング設定を元に、ロケールに対応した JSON ファイルをpublic/localesから取得し、i18nextで使用可能な状態にします。

環境変数の設定

環境変数で対応ロケールを設定すると何かと便利なため、環境変数で対応ロケールを管理する例を記載します。

# デフォルトのロケール
NEXT_PUBLIC_DEFAULT_LOCALE=ja
# 対応ロケール一覧をカンマ区切りで入力
NEXT_PUBLIC_SUPPORTED_LOCALES_SEPARATE_BY_COMMA=ja,en,zh

デフォルトのロケールと、対応ロケールの一覧はクライアント側でも使用するため、NEXT_PUBLIC_をつけます。

config 関連の設定

以下は、next-i18next 公式の setup の項を参考に作成した、環境変数に対応するnext-i18next.config.js及びnext.config.jsの例になります

next-i18next.config.jsの例

// @ts-check
/** @type {import('next-i18next').UserConfig} */
module.exports = {
  i18n: {
    defaultLocale: process.env.NEXT_PUBLIC_DEFAULT_LOCALE || 'ja',
    locales: (process.env.NEXT_PUBLIC_SUPPORTED_LOCALES_SEPARATE_BY_COMMA || 'ja').split(','),
  },
};

next.config.jsの例

const { i18n } = require('./next-i18next.config');

// @ts-check
/** @type {import('next').NextConfig} */
module.exports = {
  i18n,
};

pagesフォルダのファイル

next-i18next の appTranslationの項を参考に、_app.tsxと各ページをtsxで作る場合の例を記載します。

_app.tsxの例

_app.tsx内ではnext-i18nextの提供する、appWithTranslationを HOC として使用するよう設定します

// src/pages/_app.tsx
import type { AppProps } from 'next/app';
import { appWithTranslation } from 'next-i18next';

const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => <Component {...pageProps} />;
export default appWithTranslation(MyApp);

ページ毎の.tsxファイルの例

以下は SSG(ISR)でトップページを実装する場合の例になります。getStaticPropsまたはgetServerSideProps内で翻訳リソースを読み込み、propsとして渡す事でクライアント上で翻訳する事ができるようになります。

// src/pages/index.tsx
import type { NextPage, GetStaticProps } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { EntryHorse } from '@/types/entry-horses';
import { mockEntryHorses } from '@/mocks/entry-horse';
// 後述のreact-i18nextの型定義を追加すると、使用可能なネームスペースの名前をユニオン型で使用できる
import { Namespace } from 'react-i18next';
import nextI18NextConfig from '@/../next-i18next.config.js';

/** トップページで使用するネームスペース一覧を列挙する */
const i18nextNameSpaces: Namespace[] = ['seo', 'common', 'horse-names', 'top'];

type TopPageProps = {
  entryHorses: EntryHorse[];
};

export const getStaticProps: GetStaticProps<TopPageProps> = async ({ locale }) => {
  // 必要なリソースをロードし、propsとして受け渡す
  const translations = await serverSideTranslations(locale!, i18nextNameSpaces as string[], nextI18NextConfig);
  return { props: { ...translation, entryHorses: mockEntryHorses }, revalidate: 3600 };
};

/** トップページ */
const TopPage: NextPage<TopPageProps> = ({ entryHorses }) => {
  return (
    <>
      <TopTemplate entryHorses={entryHorses} />
    </>
  );
};

export default TopPage;

react-i18next の typescript 対応

react-i18nextは typescript 向けに、辞書を型定義する方法を提供しています。useTranslationの引数にネームスペースの名前を使用出来るようになり、指定したネームスペースに対応した翻訳キーをtの引数として使用可能になります。詳しくはこちらを参考。

react-i18next.d.ts の定義

ルート直下に@types/フォルダを作り、react-i18next.d.tsを作成します。以下はcommon seo top horse-names の 4 つのネームスペースを使用し、デフォルトで使用する言語をjaにする場合の例になります。

// @types/react-i18next.d.ts
import 'react-i18next';
import common from '../public/locales/ja/common.json';
import seo from '../public/locales/ja/seo.json';
import top from '../public/locales/ja/top.json';
import horseNames from '../public/locales/ja/horse-names.json';

declare module 'react-i18next' {
  interface CustomTypeOptions {
    defaultNS: 'common';
    resources: {
      common: typeof common;
      seo: typeof seo;
      top: typeof top;
      'horse-names': typeof horseNames;
    };
  }
}

i18n に対応した SEO 設定

SSR、SSG 時にi18nextが使用出来ることは、SEO の管理の点においても便利です。titledescription、OpenGraph 用の画像といったheadタグに入れる情報も、tを使えば言語別に作る必要がなくなります。本項ではnext-seoreact-i18nextを使用して、SEO 設定を行う方法を記載します。

NextSeo を使用する場合のページ構成

next-seoではページ共通のデフォルト設定として定義する<DefaultSeo>と、DefaultSeo を上書きする、ページ毎の設定である<NextSeo>をコンポーネントとして提供しています。

  • 全てのページで共通して読み込むコンポーネントとして、DefaultSeoをラップしたMyDefaultSeoを用意する
  • ページ毎にMyDefaultSeoとの相違点を記載する必要があるため、NextSeoをラップして作成する。
  • 2 つのコンポーネントをページ毎に読み込む。
/** トップページ */
const TopPage: NextPage<TopPageProps> = ({ entryHorses }) => {
  return (
    <>
      {/* 全ページ共通のSEO設定 */}
      <MyDefaultSeo />
      {/* ページ毎のSEO設定 */}
      <TopPageSeo />
      <TopTemplate entryHorses={entryHorses} />
    </>
  );
};

next-seo の使用法

tをつかってサイトのtitle description OG 用の画像 URL を翻訳して使用します。
また、環境変数で定めておいた対応ロケール一覧、デフォルトのロケールからcanonicalalternatesの設定を行い、他言語の場合の URL を提示し、SEO を強化します。

public/locales/ja/seo.jsonDefaultSeo用の辞書を作成。

{
  "$site-name": "サイト名",
  "$site-description": "サイトの説明文章",
  "$og-image-path": "/images/ja/og.png"
}
import { DefaultSeo, DefaultSeoProps } from 'next-seo';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';

/** サポートするロケール */
const supportedLocales = (process.env.NEXT_PUBLIC_SUPPORTED_LOCALES_SEPARATE_BY_COMMA ?? 'ja').split(',');
/** デフォルトのロケール */
const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'ja';
/** オリジン */
const defaultOrigin = process.env.NEXT_PUBLIC_FRONTEND_ORIGIN ?? 'http://localhost:3000';
/** OG画像幅。Facebook推奨に合わせ1200 */
const ogImageWidth = 1200;
/** OG画像高さ。Facebook推奨に合わせ630 */
const ogImageHeight = 630;

/**
 * ロケールとパスからURLを生成する。
 * ロケールがデフォルトの場合、ロケールを省略する。
 * パスが/の場合も省略する。
 */
function buildLocalizedUrlFromLocale(path: string, locale: string): string {
  return `${defaultOrigin}${locale === defaultLocale ? '' : `/${locale}`}${path === '/' ? '' : path}`;
}

/** NextSeoのlanguageAlternatesの型がexportされていないため型計算 */
type LanguageAlternate = Exclude<DefaultSeoProps['languageAlternates'], undefined>[number];

/** 現在のパス、指定の言語、オリジンからhrefLang用オブジェクトを生成 */
function buildLanguageAlternate(bathPath: string, hrefLang: string): LanguageAlternate {
  return {
    hrefLang,
    href: buildLocalizedUrlFromLocale(bathPath, hrefLang),
  };
}

/** 現在のパス、指定の言語一覧から、x-default設定を含むhrefLang用オブジェクトを生成 */
function buildLanguageAlternates(bathPath: string, hrefLangs: string[]): LanguageAlternate[] {
  const xDefault: LanguageAlternate = {
    hrefLang: 'x-default',
    href: buildLocalizedUrlFromLocale(bathPath, defaultLocale),
  };
  return [...hrefLangs.map((hrefLang) => buildLanguageAlternate(bathPath, hrefLang)), xDefault];
}

/** デフォルトのSEO設定。
 * 必要な部分をページ毎で上書きして使用する
 * @see https://github.com/garmeeh/next-seo
 */
export const MyDefaultSeo = (): JSX.Element => {
  const { t } = useTranslation('seo');
  // クエリを含める場合はasPathなどを使用する
  const { locale, pathname } = useRouter();
  const siteName = t('$site-name');
  const titleTemplate = `${siteName} | %s`;
  const description = t('$site-description');
  const ogImageUrl = `${defaultOrigin}${t('$og-image-path')}`;
  // 正規化したパス
  const canonical = buildLocalizedUrlFromLocale(pathname, locale!);
  // 言語毎に正規化したパスを算出する
  const languageAlternates = buildLanguageAlternates(pathname, supportedLocales);
  return (
    <DefaultSeo
      defaultTitle={siteName}
      titleTemplate={titleTemplate}
      description={description}
      canonical={canonical}
      languageAlternates={languageAlternates}
      openGraph={{
        title: siteName,
        description,
        images: [{ url: ogImageUrl, height: ogImageHeight, width: ogImageWidth, alt: siteName }],
        type: 'website',
        site_name: siteName,
        locale,
        url: canonical,
      }}
    />
  );
};

ページ毎の SEO

タイトル等の、ページ毎に変更となる箇所はNextSeoを使って定義します。

// public/locales/ja/top.json
{
  "$top-page-title": "阿寒湖特別"
}
import { NextSeo } from 'next-seo';
import { useTranslation } from 'react-i18next';

/** トップページSEO設定 */
export const TopPageSeo = (): JSX.Element => {
  // topのネームスペースにタイトルなどをいれる想定
  const { t } = useTranslation('top');
  const title = t('$top-page-title');
  return <NextSeo title={title} openGraph={{ title }} />;
};

実装例について

GitHub のSiroSuzume/typed-i18next-exampleに、今回の検証に使ったコードをあげています。

また、ja en zhに対応した example を Vercel 上にデプロイしています

その他

storybook でもreact-i18nextを有効にする方法について、後日記事にしようと思います

Discussion