POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSTwitterFacebook
Ikram Ul Haq

本記事は、原著者の許諾のもとに翻訳・掲載しております。

この記事では、Next.jsのプロジェクトにおける国際化対応(i18n)の設定方法について説明します。Next.jsが直接サポートしていない部分の課題を克服しながら、Pages Routerを使用してi18nを実装する方法と、output: exportを使用する方法について検討したいと思います。

none

国際化ルーティングは、Next.jsのルーティングレイヤーを使用しないためoutput: 'export'では実装できません。output: 'export'を使用しないHybrid Next.jsのアプリケーションは完全にサポートされています。

まず初めに、TypeScriptを使用してNext.js v14のプロジェクトを初期化しましょう。TypeScriptの代わりにJavaScriptを使用することもできます。どちらも問題なく使用できます。

NextJSプロジェクトの初期化

次のコマンドを実行してプロジェクトをセットアップしてください。。

`npx create-next-app@latest`

設定中いくつか質問をされますが、好みで回答してください。筆者の場合はPages Routerを選択し、TypeScriptでsrcディレクトリを指定しました。

次に、next.config.jsファイルにoutput: "export"を追加します。

さらに、output: "export"の構成とは互換性がないためpagesディレクトリからapiフォルダを削除します。

i18nのインストールと設定

国際化対応では、next-translateパッケージを使用します。本来はoutput: exportに対応していませんが、ご心配なく。その点については後ほどサポートします。

まずはnext-translateパッケージをインストールしてください。

`npm i next-translate`

プロジェクトのルートレベルにi18n.tsまたは.jsという名前のファイルを作成し、次のコードを挿入します。

import { I18nConfig } from "next-translate";

export const i18nConfig = {
  locales: ["en", "es"],
  defaultLocale: "en",
  loader: false,
  pages: {
    "*": ["common"],
  },
  defaultNS: "common",
} satisfies I18nConfig;

これがi18nのための基本構成になります。必要に応じて自由にカスタマイズしていただいて構いません。

次に、プロジェクトのルートレベルにlocalesフォルダを作成します。このフォルダには、各言語の翻訳ファイルが格納されます。

pagesディレクトリ内の_app.tsファイルに移動し、I18nProviderComponentをラップします。

import "@/styles/globals.css";
import type { AppProps } from "next/app";
import I18nProvider from "next-translate/I18nProvider";
import { i18nConfig } from "../../i18n";
import commonES from "../../locales/es/common.json";
import commonEN from "../../locales/en/common.json";

const App = ({ Component, pageProps, router }: AppProps) => {
  const lang = i18nConfig.locales.includes(router.query.locale as string)
    ? String(router.query.locale)
    : i18nConfig.defaultLocale;

  return (
    <I18nProvider
      lang={lang}
      namespaces={{ common: lang === "es" ? commonES : commonEN }}
    >
      <Component {...pageProps} />
    </I18nProvider>
  );
};

export default App;

この手順では以下を行いました。

  • I18nProviderComponentをラップする。
  • localeというクエリパラメタに指定された文字列から言語を検出し、propsとしてI18nProviderに渡す。
  • その言語を基に名前空間ファイルを定義する。

次に、src/hooksディレクトリにuseI18n.tsという名前のカスタムフックを作成し、そこに次のコードを挿入します。

import useTranslation from "next-translate/useTranslation";

import { i18nConfig } from "../../i18n";

interface IUseI18n {
  namespace?: string;
}

export const useI18n = ({ namespace }: IUseI18n = {}) => {
  const { t } = useTranslation(namespace ? namespace : i18nConfig.defaultNS);

  return { t };
};

このフックは、設定された名前空間またはデフォルトの名前空間をもとに翻訳を可能にします。

次に、pagesディレクトリ内のindex.tsxファイルを開き、次のコードを挿入します。

import { useI18n } from "@/hooks/useI18n";

export default function Home() {
  const { t } = useI18n();
  return (
    <div>
      <p>{t("greeting")}</p>
    </div>
  );
}

そうすると、次のような文字が表示されます。

「Hurrah(やった!)」無事プロジェクトにi18nを設定できました。しかし、言語検出がまだ残っています。次はその部分を実装しましょう。

言語検出

先ほど見たように、ロケール値はrouter.queryから取得していました。 次に進むために、pagesディレクトリ内に[locale]という名前のフォルダを作成します。 ルートファイルは全てこのフォルダの中に格納されます。 [locale]フォルダの中にindex.tsxファイルを作成し、次のコードを挿入します。

import { useI18n } from "@/hooks/useI18n";

const Home = () => {
  const { t } = useI18n();

  return (
    <div>
      <p>{t("greeting")}</p>
    </div>
  );
};

export default Home;

localhost:3000/enにアクセスするとコンテンツが英語で表示され、localhost:3000/esに変えるとスペイン語で表示されます。この機能はダイナミックルートともシームレスに統合します。

pagesフォルダは次のような構造になります。

では、next-language-detectorを使用して言語検出機能を実装し、選択した言語をキャッシュしましょう。 まずはnext-language-detectorをインストールします。

npm i next-language-detector

srcディレクトリにlibという名前のフォルダを作成し、その中にlanguageDetector.tsという名前のファイルを作成します。ファイルを開き、次のコードを挿入します。

import nextLanguageDetector from "next-language-detector";
import { i18nConfig } from "../../i18n";

export const languageDetector = nextLanguageDetector({
  supportedLngs: i18nConfig.locales,
  fallbackLng: i18nConfig.defaultLocale,
});

同じlibフォルダの中にredirect.tsxという名前のファイルを作成し、次のコードを挿入します。

import { useRouter } from "next/router";
import { useEffect } from "react";
import { languageDetector } from "./languageDetector";

export const useRedirect = (to?: string) => {
  const router = useRouter();
  const redirectPath = to || router.asPath;

  // language detection
  useEffect(() => {
    const detectedLng = languageDetector.detect();
    if (redirectPath.startsWith("/" + detectedLng) && router.route === "/404") {
      // prevent endless loop
      router.replace("/" + detectedLng + router.route);
      return;
    }

    if (detectedLng && languageDetector.cache) {
      languageDetector.cache(detectedLng);
    }
    router.replace("/" + detectedLng + redirectPath);
  });

  return <></>;
};

次に、pagesディレクトリの中([locale]フォルダの外)にあるindex.tsxファイルを開き、既存のコードを次のコードに置き換えます。

import { useRedirect } from "@/lib/redirect";

const Redirect = () => {
  useRedirect();
  return <></>;
};

export default Redirect;

これで、ロケールを指定せずにホームページにアクセスを試みたユーザーは、ロケールページにリダイレクトされるようになります。

しかし、[locale]フォルダ内の全てのルートについてリダイレクトページを作成するのはあまり効率的ではありません。したがって、ユーザーが言語を指定せずにページにアクセスしようとした場合に特定言語のルートにリダイレクトするLanguageWrapperを作成します。

まずはsrcディレクトリの中にwrappersという名前のフォルダを作成します。このフォルダの中にLanguageWrapper.tsxという名前のファイルを作成し、次のコードを挿入します。

import { ReactNode, useEffect } from "react";
import { useRouter } from "next/router";
import { languageDetector } from "@/lib/languageDetector";
import { i18nConfig } from "../../i18n";

interface LanguageWrapperProps {
  children: ReactNode;
}

export const LanguageWrapper = ({ children }: LanguageWrapperProps) => {
  const router = useRouter();
  const detectedLng = languageDetector.detect();

  useEffect(() => {
    const {
      query: { locale },
      asPath,
      isReady,
    } = router;

    // Check if the current route has accurate locale
    if (isReady && !i18nConfig.locales.includes(String(locale))) {
      if (asPath.startsWith("/" + detectedLng) && router.route === "/404") {
        return;
      }

      if (detectedLng && languageDetector.cache) {
        languageDetector.cache(detectedLng);
      }
      router.replace("/" + detectedLng + asPath);
    }
  }, [router, detectedLng]);

  return (router.query.locale &&
    i18nConfig.locales.includes(String(router.query.locale))) ||
    router.asPath.includes(detectedLng ?? i18nConfig.defaultLocale) ? (
    <>{children}</>
  ) : (
    <p>Loading...</p>
  );
};

次に、_app.tsxファイルにLanguageWrapperを追加します。

あともう一息です。src/components/_sharedディレクトリにLink.tsxという名前のコンポーネントを作成し、次のコードを挿入します。

import { ReactNode } from "react";
import NextLink from "next/link";
import { useRouter } from "next/router";

interface LinkProps {
  children: ReactNode;
  skipLocaleHandling?: boolean;
  locale?: string;
  href: string;
  target?: string;
}

export const Link = ({
  children,
  skipLocaleHandling,
  target,
  ...rest
}: LinkProps) => {
  const router = useRouter();
  const locale = rest.locale || (router.query.locale as string) || "";

  let href = rest.href || router.asPath;
  if (href.indexOf("http") === 0) skipLocaleHandling = true;
  if (locale && !skipLocaleHandling) {
    href = href
      ? `/${locale}${href}`
      : router.pathname.replace("[locale]", locale);
  }

  return (
    <NextLink href={href} target={target}>
      {children}
    </NextLink>
  );
};

このプロジェクトでは、next/linkコンポーネントの代わりにこのLinkコンポーネントを使用します。

次に、hooksフォルダの中にuseRouteRedirect.tsという名前のファイルを作成し、次のコードを挿入します。

import { useRouter } from "next/router";
import { i18nConfig } from "../../i18n";
import { languageDetector } from "@/lib/languageDetector";

export const useRouteRedirect = () => {
  const router = useRouter();

  const redirect = (to: string, replace?: boolean) => {
    const detectedLng = i18nConfig.locales.includes(String(router.query.locale))
      ? String(router.query.locale)
      : languageDetector.detect();
    if (to.startsWith("/" + detectedLng) && router.route === "/404") {
      // prevent endless loop
      router.replace("/" + detectedLng + router.route);
      return;
    }

    if (detectedLng && languageDetector.cache) {
      languageDetector.cache(detectedLng);
    }
    if (replace) {
      router.replace("/" + detectedLng + to);
    } else {
      router.push("/" + detectedLng + to);
    }
  };

  return { redirect };
};

以下に示すとおり、router.pushrouter.replaceの代わりにこのカスタムフックを使用します。

次に、src/componentsディレクトリの中にLanguageSwitcher.tsxコンポーネントを作成し、次のコードで特定の言語に切り替えられるようにします。

import { languageDetector } from "@/lib/languageDetector";
import { useRouter } from "next/router";
import Link from "next/link";

interface LanguageSwitcherProps {
  locale: string;
  href?: string;
  asPath?: string;
}

export const LanguageSwitcher = ({
  locale,
  ...rest
}: LanguageSwitcherProps) => {
  const router = useRouter();

  let href = rest.href || router.asPath;
  let pName = router.pathname;
  Object.keys(router.query).forEach((k) => {
    if (k === "locale") {
      pName = pName.replace(`[${k}]`, locale);
      return;
    }
    pName = pName.replace(`[${k}]`, String(router.query[k]));
  });
  if (locale) {
    href = rest.href ? `/${locale}${rest.href}` : pName;
  }

  return (
    <Link
      href={href}
      onClick={() =>
        languageDetector.cache ? languageDetector.cache(locale) : {}
      }
    >
      <button style={{ fontSize: "small" }}>{locale}</button>
    </Link>
  );
};

お疲れ様です!output: exportを使用してNext.jsのプロジェクトにi18nを無事実装できました。動的に名前空間を切り替える場合など、異なる名前空間から翻訳を読み込みたい場合は、I18nProviderコンポーネントの中の_app.tsxで各名前空間とそれぞれに対応する翻訳ファイルを定義する必要があるということを覚えておいてください。

ビルドを作成してからテストを行う前に、pagesディレクトリに404.tsxファイルを作成し、次のコードを挿入してください。

import { FC, useEffect, useState } from "react";
import { NextRouter, useRouter } from "next/router";
import { getRouteRegex } from "next/dist/shared/lib/router/utils/route-regex";
import { getClientBuildManifest } from "next/dist/client/route-loader";
import { parseRelativeUrl } from "next/dist/shared/lib/router/utils/parse-relative-url";
import { isDynamicRoute } from "next/dist/shared/lib/router/utils/is-dynamic";
import { removeTrailingSlash } from "next/dist/shared/lib/router/utils/remove-trailing-slash";
import { Link } from "@/components/_shared/Link";

async function getPageList() {
  if (process.env.NODE_ENV === "production") {
    const { sortedPages } = await getClientBuildManifest();
    return sortedPages;
  } else {
    if (typeof window !== "undefined" && window.__BUILD_MANIFEST?.sortedPages) {
      console.log(window.__BUILD_MANIFEST.sortedPages);
      return window.__BUILD_MANIFEST.sortedPages;
    }
  }
  return [];
}

async function getDoesLocationMatchPage(location: string) {
  const pages = await getPageList();

  let parsed = parseRelativeUrl(location);
  let { pathname } = parsed;
  return pathMatchesPage(pathname, pages);
}

function pathMatchesPage(pathname: string, pages: string[]) {
  const cleanPathname = removeTrailingSlash(pathname);

  if (pages.includes(cleanPathname)) {
    return true;
  }

  const page = pages.find(
    (page) => isDynamicRoute(page) && getRouteRegex(page).re.test(cleanPathname)
  );

  if (page) {
    return true;
  }
  return false;
}

/**
 * If both asPath and pathname are equal then it means that we
 * are on the correct route it still doesnt exist
 */
function doesNeedsProcessing(router: NextRouter) {
  const status = router.pathname !== router.asPath;
  console.log("Does Needs Processing", router.asPath, status);
  return status;
}

const Custom404 = () => {
  const router = useRouter();

  const [isNotFound, setIsNotFound] = useState(false);

  const processLocationAndRedirect = async (router: NextRouter) => {
    if (doesNeedsProcessing(router)) {
      const targetIsValidPage = await getDoesLocationMatchPage(router.asPath);
      if (targetIsValidPage) {
        await router.replace(router.asPath);
        return;
      }
    }
    setIsNotFound(true);
  };

  useEffect(() => {
    if (router.isReady) {
      processLocationAndRedirect(router);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [router.isReady]);

  if (!isNotFound) return null;

  return (
    <div className="fixed inset-0 flex justify-center items-center">
      <div className="flex flex-col gap-10">
        <h1>Custom 404 - Page Not Found</h1>
        <Link href="/">
          <button>Go to Home Page</button>
        </Link>
      </div>
    </div>
  );
};

export default Custom404;

ダイナミックルートでページが見つからないエラーを解決するために、pagesディレクトリに404.tsxファイルを入れておく必要があります。 また、ビルド作成後にコードを実行するために、package.jsonファイルに次のコマンドを追加してください。

"preview": "serve out/ -p 3000"

serveパッケージがない場合はインストールしてください。

npm i -D serve

npm run buildnpm run previewを実行した後、ポート3000でプロジェクトにアクセスできます。 コード全体はGitHubにあります。一部微調整を加えていますので、よろしければご覧ください。 何かお気づきの点やエラーなどがあればぜひコメントをください。喜んでサポートいたします。よろしくお願いします!

監修者
監修者_古川陽介
古川陽介
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。