Next.jsのStatic Exportsで自作i18n対応する Sub-path Routing

## はじめに

Next.js(v14時点)では標準でi18nがあるもののStatic Exportsでは利用できないようです。
参考: [Routing: Internationalization | Next.js](https://nextjs.org/docs/pages/building-your-application/routing/internationalization#how-does-this-work-with-static-generation) (2024-04-27)

[martinkr/next-export-i18n](https://github.com/martinkr/next-export-i18n) というライブラリもありますが、Sub-path Routingしたかったのと外部ライブラリ利用をなるべく控えたかったので、オレオレで自作してみました。意外と簡単だった & 現時点ではこういった記事が少なそうだったのでまとめました。

## 今回のSub-path Routingの方針

i18nはSub-path Routingで対応しました。pathに `en` や `ja` を含めるやり方です。

また、Sub-path Routingでもさらに以下の方針があるかと思います。両方作ってみたので以降で両方紹介します。

1. 単にアクセスしたページにアクセスする
1. LocalStorageに言語を保持して、言語が異なるpathならredirectする

## 実装

今回紹介するコードはこちらにもpushしてます。
[playground_next_ssg/hosting at main · koshitake2m2/playground_next_ssg](https://github.com/koshitake2m2/playground_next_ssg/tree/main/hosting)

完成イメージはこちらです。

|`/lang/en`|`/lang/ja`|
|---|---|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206540/053ddd22-a2fe-672a-d409-ddd8d3b1ef01.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206540/87a7f723-f038-935c-bd94-9d8399866dcc.png)|


### 共通の準備

#### directory構成

```bash
.
├── app
│   └── lang
│       ├── [lang]
│       │   └── page.tsx
│       ├── page.tsx
│       └── switch-lang.tsx
├── i18n
│   ├── i18n-type.ts
│   ├── i18n.en.ts
│   ├── i18n.ja.ts
│   └── i18n.ts
└── public
    └── lang
        └── redirect.js
```

※みやすさ重視でいくらか省いてます

#### Static Exports

```next.config.js
const nextConfig = {
  output: "export",
  ...
};
module.exports = nextConfig;
```

#### i18nのオブジェクトの準備

デフォルトの言語を `en` とします。

```i18n-type.ts
export type LangType = "en" | "ja";
export type I18nType = {
  lang: LangType;
  greeting: string;
  welcome: string;
  say: (words: string) => string;
  a: {
    b: string;
  };
};
```

```i18n.en.ts
import { I18nType } from "./i18n-type";

export const i18nEn = {
  lang: "en",
  greeting: "Hello",
  welcome: "Welcome",
  say: (words: string) => `say ${words}`,
  a: {
    b: "ab",
  },
} as const satisfies I18nType;
```

```i18n.ja.ts
import { I18nType } from "./i18n-type";

export const i18nJa = {
  lang: "ja",
  greeting: "こんにちは",
  welcome: "ようこそ",
  say: (words: string) => `「${words}」と言う`,
  a: {
    b: "abだよ",
  },
} as const satisfies Partial<I18nType>;
```

```i18n.ts
import { I18nType, LangType } from "./i18n-type";
import { i18nEn } from "./i18n.en";
import { i18nJa } from "./i18n.ja";

export const langPathParams: { lang: LangType }[] = [
  { lang: "en" },
  { lang: "ja" },
];

export const i18nMap: Record<LangType, I18nType> = {
  en: i18nEn,
  ja: { ...i18nEn, ...i18nJa }, // i18nJaを書き漏れたらi18nEnが出る
};
```

上記の実装であれば以下のような嬉しさがあります。

- リネームしやすい
- 利用箇所を特定しやすい
- 利用時に補完が効く
- 階層を表現できる
- 関数を利用できるのでコンポーネントから任意の文字を渡せる

また、お好みで以下のようにしてもいいでしょう

- `I18nType` を定義しない
- `type I18nType = typeof i18nEn` で定義する
- デフォルト以外の言語も型で縛る

### 1. 単にアクセスしたページにアクセスする版

#### 挙動

- `/lang`
    - アクセスしたらブラウザの言語をもとに `/lang/en` か `/lang/ja` にリダイレクトする
- `/lang/en`
    - 単に英語ページ
- `/lang/ja`
    - 単に日本語ページ

#### 画面準備

##### `/lang`

```public/lang/redirect.js
const browserLang = navigator.language;
const path = browserLang.startsWith("ja") ? "/lang/ja" : "/lang/en";
window.location = path;
```

```app/lang/page.tsx
import Script from "next/script";

export default function LangPage() {
  return (
    <>
      <Script src={`/lang/redirect.js`}></Script>
    </>
  );
}
```

##### `/lang/[lang]`

```app/lang/[lang]/page.tsx
import Link from "next/link";
import { i18nMap, langPathParams } from "../../../i18n/i18n";
import { SwitchLang } from "../switch-lang";
import { LangType } from "../../../i18n/i18n-type";

export function generateStaticParams() {
  return langPathParams;
}

export default function LangPage({ params }: { params: { lang: LangType } }) {
  const { lang } = params;
  const i18n = i18nMap[lang];

  return (
    <>
      <h1>lang</h1>
      <p>lang: {lang}</p>
      <p>welcome: {i18n.welcome}</p>
      <Link href={`/lang/${lang}/hello`}>hello page</Link>
      <SwitchLang />
    </>
  );
}
```

言語を変えるボタン

```app/lang/switch-lang.tsx
"use client";

import { usePathname, useRouter } from "next/navigation";
import { LangType } from "../../i18n/i18n-type";

export function SwitchLang() {
  const router = useRouter();
  const pathname = usePathname();

  const onClickSwitchLang = (lang: LangType) => {
    const newPath = pathname.replace(/^\/lang\/[^/]*/, `/lang/${lang}`);
    router.push(newPath);
  };

  return (
    <>
      <h1>switch lang</h1>

      <button onClick={() => onClickSwitchLang("en")}>en</button>
      <button onClick={() => onClickSwitchLang("ja")}>ja</button>
    </>
  );
}
```


### 2. LocalStorageに言語を保持して、言語が異なるpathならredirectする版

#### 挙動

- 各ページ共通
    - LocalStorageに言語が保存されてる場合
        - 該当言語のページにリダイレクトする
    - LocalStorageに言語が保存されてない場合
        - ブラウザの言語をもとに該当言語のページにリダイレクトする
- `/lang`
    - アクセスしたら `/lang/en` か `/lang/ja` にリダイレクトする
- `/lang/en`
    - 単に英語ページ
- `/lang/ja`
    - 単に日本語ページ


#### `/lang`

単に空のページ

```app/lang/page.tsx
export default function LangLocalStoragePage() {
  return <></>;
}
```

各ページ共通のリダイレクト用のscriptを準備

```app/lang/layout.tsx
import Script from "next/script";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <Script src={`/lang/redirect.js`}></Script> {children}
    </>
  );
}
```

LocalStorageやブラウザの言語を取得してリダイレクトする処理。ゴリゴリです。

```public/lang/redirect.js
/**
 * @param {string} lang
 */
const redirectLang = (lang) => {
  const newPath = location.pathname.replace(
    /^\/lang\/[^/]*/,
    `/lang/${lang}`
  );
  window.location = newPath;
};
const savedLang = localStorage.getItem("lang");

if (savedLang && ["en", "ja"].includes(savedLang)) {
  const pathLang = location.pathname.match(
    /^\/lang\/([^/]*)/
  )?.[1];
  if (pathLang !== savedLang) {
    redirectLang(savedLang);
  }
} else {
  const browserLang = navigator.language;
  const newLang = browserLang.startsWith("ja") ? "ja" : "en";
  localStorage.setItem("lang", newLang);

  const pathLang = location.pathname.match(
    /^\/lang\/([^/]*)/
  )?.[1];
  if (pathLang !== newLang) {
    redirectLang(newLang);
  }
}
```

```app/lang/[lang]/page.tsx
import Link from "next/link";
import { i18nMap, langPathParams } from "../../../i18n/i18n";
import { SwitchLangLocalStorage } from "../switch-lang";
import { LangType } from "../../../i18n/i18n-type";

export function generateStaticParams() {
  return langPathParams;
}

export default function LangPage({ params }: { params: { lang: LangType } }) {
  const { lang } = params;
  const i18n = i18nMap[lang];

  return (
    <>
      <h1>lang</h1>
      <p>lang: {lang}</p>
      <p>welcome: {i18n.welcome}</p>
      <Link href={`/lang/${lang}/hello`}>hello page</Link>
      <SwitchLangLocalStorage />
    </>
  );
}
```

言語切り替えボタン。LocalStorageに言語をsetする。

```app/lang/switch-lang.tsx
"use client";

import { usePathname, useRouter } from "next/navigation";
import { LangType } from "../../i18n/i18n-type";

export function SwitchLangLocalStorage() {
  const router = useRouter();
  const pathname = usePathname();

  const onClickSwitchLang = (lang: LangType) => {
    const newPath = pathname.replace(
      /^\/lang\/[^/]*/,
      `/lang/${lang}`
    );
    localStorage.setItem("lang", lang);
    router.push(newPath);
  };

  return (
    <>
      <h1>switch lang</h1>

      <button onClick={() => onClickSwitchLang("en")}>en</button>
      <button onClick={() => onClickSwitchLang("ja")}>ja</button>
    </>
  );
}
```

## 補足

- App Routerで書いてますが、pages directoryでも同様にできます
- 今回は `/lang/en` にしてますが、 `/en` のようにトップのpathにすることもできます
- redirect用の処理をゴリゴリにjsで書いてますが、もっとスマートな書き方があればゆるぼです🙏

## 参考

同じく自作でi18n対応してる方や別ライブリ利用の記事を見つけましたので共有です。

- [【令和最新版】Next.js の Static Exports で i18n すると疲弊する【App Router 対応】 - Diary of a Perpetual Student](https://blog.arthur1.dev/entry/2023/06/04/100000)
- [Next.jsのexport(SSG)してi18n（国際化）実装を行いたい時のススメ](https://zenn.dev/kisukeyas/articles/580f376767f6be)