2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-04-27

はじめに

Next.js(v14時点)では標準でi18nがあるもののStatic Exportsでは利用できないようです。
参考: Routing: Internationalization | Next.js (2024-04-27)

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

今回のSub-path Routingの方針

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

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

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

実装

今回紹介するコードはこちらにもpushしてます。
playground_next_ssg/hosting at main · koshitake2m2/playground_next_ssg

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

/lang/en /lang/ja
image.png image.png

共通の準備

directory構成

.
├── 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対応してる方や別ライブリ利用の記事を見つけましたので共有です。

2
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?