2025年3月24日
NextJSのoutput: exportによるi18n


(2024-2-7)by 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
ファイルに移動し、I18nProvider
でComponent
をラップします。
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;
この手順では以下を行いました。
I18nProvider
でComponent
をラップする。- 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.push
とrouter.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 build
とnpm run preview
を実行した後、ポート3000でプロジェクトにアクセスできます。
コード全体はGitHubにあります。一部微調整を加えていますので、よろしければご覧ください。
何かお気づきの点やエラーなどがあればぜひコメントをください。喜んでサポートいたします。よろしくお願いします!
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa