😺

【Next.js App Router】ライブラリを使わずに型安全な i18n を実装する

2025/01/29に公開

Next.js の App Router は強力な機能を提供しますが、i18n に関しては next-i18next などのライブラリを使用するのが一般的です。しかし、これらのライブラリは便利な反面、Next.js のメジャーアップデートのたびに互換性の問題が発生しやすく、対応に追われることも少なくありません。

本記事では、Next.js v15 の App Router で、ライブラリに依存せずに、必要最小限の実装で型安全な i18n を実現する方法を紹介します。

実装方針

  • 型安全: TypeScript を活用し、翻訳キーやロケールの型を定義することで、コンパイル時にエラーを検知します。
  • 自動化: スクリプトを用いて翻訳ファイルから型定義を自動生成し、手動管理の手間を削減します。
  • サーバー/クライアント対応: サーバーコンポーネントとクライアントコンポーネントの両方で翻訳関数を使用できるようにします。
  • ロケール検出: Cookie と Accept-Language ヘッダーからロケールを検出し、適切な言語を表示します。

この実装の何が嬉しいのか?

この型安全な自作 i18n 実装には、以下のような嬉しいポイントがあります。

  • タイプミス・翻訳漏れをコンパイル時に発見!: i18nKeys 型によって、翻訳キーのタイプミスや、存在しないキーの使用をコンパイルエラーとして検出できます。これにより、実行時にエラーが発生したり、翻訳が正しく表示されなかったりする問題を未然に防ぐことができます。npm run update:i18n を実行し忘れていなければ、タイプミスや翻訳漏れといった不具合に悩むことはなくなるでしょう。
  • 安全なリファクタリング!: 翻訳キーを変更・削除する際に、i18nKeys 型が自動的に更新されるため、影響範囲を簡単に把握できます。キーの変更漏れや、不要なコードの残存を防ぎ、安全かつ効率的にリファクタリングを行えます。
  • 入力補完で爆速コーディング!: エディタの入力補完機能により、翻訳キーを正確かつ迅速に入力できます。t("home.ti..." と入力するだけで、home.title をサジェストしてくれるので、コーディング速度が大幅に向上します。
  • 学習コストが低い!: シンプルな実装なので、チームメンバーへの共有や、新規メンバーのオンボーディングもスムーズに行えます。
  • Next.js のアップデートに強い!: ライブラリに依存していないため、Next.js のメジャーアップデートに迅速に対応できます。

これらのメリットにより、開発効率の向上、バグの削減、保守性の高いアプリケーションの実現が期待できます。

補足:なぜライブラリを使わないのか?

主な理由は以下の通りです。

  • 依存関係の削減: ライブラリの使用は、プロジェクトの依存関係を増やし、ビルド時間やバンドルサイズの増加に繋がる可能性があります。
  • アップデートへの迅速な対応: Next.js のメジャーアップデート時、ライブラリの対応を待つことなく、自身のコードの修正だけで迅速に対応できます。
  • 学習コストの削減: 独自のシンプルな実装であれば、学習コストを抑え、チームメンバーへの共有も容易になります。
  • コードの透明性: ライブラリ内部の処理を理解する必要がなく、コードの全体像を把握しやすくなります。

もちろん、ライブラリには豊富な機能やコミュニティサポートなどのメリットもあります。しかし、本記事では、必要最小限の機能に絞り、シンプルさと保守性を重視したアプローチを取ります。

1. 翻訳ファイルの準備

まず、src/i18n/locales ディレクトリを作成し、各言語の翻訳ファイルを JSON 形式で配置します。

src/
└── i18n/
└── locales/
├── en.json
└── ja.json

ja.json の例:

{
  "home": {
    "title": "ホーム",
    "welcome": "ようこそ、{{name}}さん!"
  },
  "settings": {
    "title": "設定",
    "language": "言語"
  }
}

en.json の例:

{
  "home": {
    "title": "Home",
    "welcome": "Welcome, {{name}}!"
  },
  "settings": {
    "title": "Settings",
    "language": "Language"
  }
}

2. 翻訳キーの型定義を自動生成

ja.json から TypeScript の型定義を自動生成するスクリプトを作成します。

scripts/update-i18n-keys.js

const fs = require("fs");
const path = require("path");

const inputFilePath = path.join(__dirname, "../src/i18n/locales/ja.json");
const outputFilePath = path.join(__dirname, "../src/i18n/locales/keys.ts");

function getKeys(obj, prefix = "") {
  let keys = [];
  for (const key in obj) {
    const value = obj[key];
    const newKey = prefix ? `${prefix}.${key}` : key;
    if (typeof value === "object" && value !== null) {
      keys = keys.concat(getKeys(value, newKey));
    } else {
      keys.push(newKey);
    }
  }
  return keys;
}

function main() {
  const jsonData = JSON.parse(fs.readFileSync(inputFilePath, "utf8"));
  const keys = getKeys(jsonData);

  const tsContent = `export type i18nKeys = ${keys
    .map((k) => `"${k}"`)
    .join(" | ")};`;

  fs.writeFileSync(outputFilePath, tsContent, "utf8");
  console.log(`Generated keys.ts with ${keys.length} keys.`);
}

main();

このスクリプトは ja.json を読み込み、ネストされたキーを再帰的に処理して、i18nKeys 型を生成します。

package.json にスクリプトを追加します。

{
  "scripts": {
    "update:i18n": "node scripts/update-i18n-keys.js"
  }
}

npm run update:i18n を実行すると、src/i18n/locales/keys.ts が生成されます。

src/i18n/locales/keys.ts

export type i18nKeys = "home.title" | "home.welcome" | "settings.title" | "settings.language";

3. 言語リソースとユーティリティ関数

言語リソース、サポートされるロケール、およびユーティリティ関数を定義します。

src/i18n/resources.ts

import en from "./locales/en.json";
import ja from "./locales/ja.json";

export const RESOURCES = { ja, en };
export const SUPPORTED_LOCALES = Object.keys(RESOURCES) as Locale[];
export const DEFAULT_LOCALE = "ja";

export type Locale = keyof typeof RESOURCES;

export const isSupportLocale = (locale: string | undefined): locale is Locale =>
  locale !== undefined && Object.keys(RESOURCES).includes(locale);

/**
 * ネストしたオブジェクトから値を取得する関数
 * @param obj ネストしたオブジェクト
 * @param path キーのパス
 * @returns 値
 */
export const getNestedValue = (
  obj: Record<string, any>,
  path: string
): string => {
  return path.split(".").reduce<any>((acc, key) => {
    if (acc && typeof acc === "object" && key in acc) {
      return acc[key];
    }
    return path;
  }, obj);
};

4. クライアントサイド用の翻訳フック

クライアントコンポーネントで使用する useTranslation フックを作成します。

src/i18n/client.tsx

"use client";

import { createContext, useContext, useCallback } from "react";
import {
  RESOURCES,
  DEFAULT_LOCALE,
  Locale,
  isSupportLocale,
  getNestedValue,
} from "./resources";
import { i18nKeys } from "./locales/keys";

const LocaleContext = createContext<Locale>(DEFAULT_LOCALE);

type LocaleProviderProps = {
  children: React.ReactNode;
  locale: Locale;
};

export const LocaleProvider = ({ children, locale }: LocaleProviderProps) => {
  return (
    <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>
  );
};

/**
 * クライアントサイドでの翻訳関数
 * @returns 翻訳関数
 */
export const useTranslation = (): {
  t: (key: i18nKeys, params?: Record<string, string>) => string;
} => {
  const currentLocale = useContext(LocaleContext);
  if (!isSupportLocale(currentLocale)) {
    throw new Error(`Unsupported locale: ${currentLocale}`);
  }

  const translate = useCallback(
    (key: i18nKeys, params?: Record<string, string>) => {
      const value = getNestedValue(RESOURCES[currentLocale], key);
      if (value === undefined) {
        console.warn(`Missing translation for key: ${key}`);
        return key;
      }

      // パラメータを置換
      if (params) {
        return value.replace(/{{(.*?)}}/g, (match, p1) => params[p1] || match);
      }

      return value;
    },
    [currentLocale]
  );

  return { t: translate };
};

5. サーバーサイド用の翻訳関数

サーバーコンポーネントで使用する getTranslation 関数を作成します。
また、ユーザーのロケールを扱うサービスを定義します。

src/services/UserLocaleService.ts

import { cookies, headers } from "next/headers";
import { SUPPORTED_LOCALES, Locale } from "@/i18n/resources";

const COOKIE_KEY = "locale";

export namespace UserLocaleService {
    /**
    * Cookieからロケールを取得する
    * @returns ロケール
    */
    export const getCookieLocale = async (): Promise<Locale | undefined> => {
        const cookieStore = cookies();
        const locale = cookieStore.get(COOKIE_KEY)?.value;
        return SUPPORTED_LOCALES.find((l) => l === locale);
    };

    /**
    * Accept-Languageヘッダーからロケールを取得する
    * @returns ロケール
    */
    export const getAcceptLanguageLocale = async (): Promise<Locale | undefined> => {
        const acceptLanguage = headers().get("Accept-Language");
        if (!acceptLanguage) return undefined;
        const acceptedLanguages = acceptLanguage
            .split(",")
            .map((lang) => lang.split(";")[0].trim());

        return SUPPORTED_LOCALES.find((locale) =>
            acceptedLanguages.includes(locale)
        );
    };
}

src/i18n/server.ts

import "server-only";

import {
  RESOURCES,
  isSupportLocale,
  getNestedValue,
  Locale,
} from "./resources";
import { i18nKeys } from "./locales/keys";
import { UserLocaleService } from "@/services/UserLocaleService";

/**
 * サーバーサイドでの翻訳関数
 * @param locale ロケール
 * @returns 翻訳関数
 */
export const getTranslation = async (
  selectedLocale?: Locale
): Promise<{
  t: (key: i18nKeys, params?: Record<string, string>) => string;
}> => {
  const cookieLanguage = await UserLocaleService.getCookieLocale();
  const acceptLanguage = await UserLocaleService.getAcceptLanguageLocale();
  const locale = selectedLocale ?? cookieLanguage ?? acceptLanguage;

  if (!isSupportLocale(locale)) {
    throw new Error(`Unsupported locale: ${locale}`);
  }

  return {
    t: (key: i18nKeys, params?: Record<string, string>) => {
      const value = getNestedValue(RESOURCES[locale], key);
      if (value === undefined) {
        console.warn(`Missing translation for key: ${key}`);
        return key;
      }

      // パラメータを置換
      if (params) {
        return value.replace(/{{(.*?)}}/g, (match, p1) => params[p1] || match);
      }

      return value;
    },
  };
};

6. ルートレイアウト

app/[locale]/layout.tsxで、LocaleProvider を使用して、子コンポーネントにロケールを渡します。

src/app/[locale]/layout.tsx

import React from "react";
import { LocaleProvider } from "@/i18n/client";
import { Locale, DEFAULT_LOCALE, isSupportLocale } from "@/i18n/resources";
import { UserLocaleService } from "@/services/UserLocaleService";

export default async function Layout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: { locale: string };
}) {
    const cookieLanguage = await UserLocaleService.getCookieLocale();
    const acceptLanguage = await UserLocaleService.getAcceptLanguageLocale();
    const locale = isSupportLocale(params.locale)
        ? params.locale
        : cookieLanguage ?? acceptLanguage ?? DEFAULT_LOCALE;

  return (
    <html>
      <body>
        <LocaleProvider locale={locale}>{children}</LocaleProvider>
      </body>
    </html>
  );
}

7. 使用例

クライアントコンポーネント:

"use client"
import { useTranslation } from "@/i18n/client";

export default function Home() {
  const { t } = useTranslation();

  return (
    <div>
      <h1>{t("home.title")}</h1>
      <p>{t("home.welcome", { name: "John" })}</p>
    </div>
  );
}

サーバーコンポーネント:

import { getTranslation } from "@/i18n/server";

export default async function Settings() {
  const { t } = await getTranslation();

  return (
    <div>
      <h1>{t("settings.title")}</h1>
      <p>{t("settings.language")}</p>
    </div>
  );
}

まとめ

この方法により、ライブラリに依存せずに、Next.js App Router で型安全な i18n を最小限の実装で実現できます。update:i18n スクリプトを実行するだけで、翻訳ファイルの変更が自動的に型定義に反映され、安全かつ効率的に開発を進められます。

このシンプルな実装は、小規模プロジェクトや、ライブラリの制約を受けたくない場合に特に有効です。もちろん、より高度な機能が必要な場合は、next-i18next などのライブラリの利用を検討するのも良いでしょう。

このガイドが、Next.js での i18n 実装の助けになれば幸いです。

補足

この記事はGemini 2.0 Experimental Adbancedを用いて作成しました。

Discussion