Diary of a Perpetual Student

Perpetual Student: A person who remains at university far beyond the normal period

【令和最新版】Next.js の Static Exports で i18n すると疲弊する【App Router 対応】

  • 2023-06-04 18:00: 誤りのある表現を修正しました
    • s/Static Generation/Static Exports/ *1

結論

Next.js の i18n 機能は Static Exports 未サポートなので自前で頑張れ!

Next.js App Router の Static Exports で i18n したい!

今回、以下のような要件を決めて、実現できるかチャレンジしました:

  • Next.js の App Router を用いる
  • 言語は日本語(ja)と英語(en)の 2種類
  • サブパスで言語を分けるが、デフォルト言語のサブパスは切りたくない
    • 日本語ページのパスが /hogehoge/ なら、英語ページは /en/hogehoge/ という感じにしたい
  • output: 'export' モードにし、Static Exports する
  • 生成された各ページの HTML に、localize された後のテキストがそのまま埋め込まれている

ゴリ押しは全てを解決する

試行錯誤した結果、以下のコードのようになりました。

github.com

なぜゴリ押ししたかというと、Next.js の i18n 機能は Static Exports をサポートしていないからです。

コード解説

Server Component での i18n の実現

以下の記事を読んで大体同じようにコードを書きました。ここに書かれている通りにカスタムフックを作ると、App Router を用いた Server Componnent を i18n することができます。

locize.com

import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'

export const defaultLanguage = 'ja'
export const nonDefaultLanguages = ['en'] as const
export const languages = [defaultLanguage, ...nonDefaultLanguages] as const
export type Language = (typeof languages)[number]

type useTranslationProps = Readonly<{
  lang: string
  translationDef: Readonly<{
    [L in Language]: Readonly<{}>
  }>
}>

export const useTranslation = async ({ lang, translationDef }: useTranslationProps) => {
  const i18n = createInstance()
  await i18n
    .use(initReactI18next)
    .use(resourcesToBackend(translationDef))
    .init({
      debug: process.env.NODE_ENV === 'development',
      supportedLngs: languages,
      fallbackLng: defaultLanguage,
      lng: lang,
    })

  return {
    t: i18n.getFixedT(lang),
    i18n,
  }
}

デフォルト言語だけサブパスを切らないルーティングの実現

app ディレクトリ以下を以下のような構成にしました

残念ながらこうするしかないようです。[lang] は動的ルーディングで、ここに en が入るというイメージ。(root)/ 以下の方が日本語のページに対応します。

ポイントは (root)/ というディレクトリ。こいつを app/ 直下に配置すると、[lang]/ 以下のページを閲覧したときに両方の layout が使われマトリョーシカになってしまいます。App Router には route group という仕組みがあり、() で囲まれたディレクトリを用いると、URL のパスに影響を与えることなく component を分類することができます。

(root)/ と [lang]/ 以下に同じようなコードを書くのは馬鹿らしいので、(root)/ 以下のファイルは [lang]/ 以下で定義された component を import し default の言語を props で渡してあげるようにしています。

app/(root)/page.tsx

import IndexPage from '../[lang]/page'
import { defaultLanguage } from '../_hooks/i18n'

export default async function DefaultIndexPage() {
  return await IndexPage({
    params: {
      lang: defaultLanguage,
    },
  })
}

app/[lang]/page.tsx

import { nonDefaultLanguages, useTranslation } from '../_hooks/i18n'
import translationDef from './translationDef'

export const generateStaticParams = async () => nonDefaultLanguages.map(lang => ({ lang }))

type IndexPageProps = Readonly<{
  params: {
    lang: string
  }
}>

export default async function IndexPage({ params }: IndexPageProps) {
  const { lang } = params
  const { t } = await useTranslation({ lang, translationDef })
  return (
    <main>
      <h1>{t('greeting')}</h1>
    </main>
  )
}

(root) 以下のファイルをコード生成するツールを自作すると捗りそうですね。

言語の切り替えリンク

以下のようなコードで、言語だけ切り替えられるようなリンクを作りました。hogehoge/ ページにいる場合は en/hogehoge/ に、 en/hogehoge/ ページにいる場合は hogehoge/ にリンクする、といった挙動です。

'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'

import { nonDefaultLanguages } from '@/app/_hooks/i18n'

type ToggleLanguageLinkProps = Readonly<{
  children: React.ReactNode
}>

export default function ToggleLanguageLink({ children }: ToggleLanguageLinkProps) {
  const pathname = usePathname()
  const enPrefix = `/${nonDefaultLanguages[0]}`
  let newPathname: string
  if (pathname.startsWith(enPrefix)) {
    newPathname = pathname.substring(enPrefix.length)
  } else {
    newPathname = `${enPrefix}${pathname}`
  }
  return <Link href={newPathname}>{children}</Link>
}

ページ内容を維持するために現在のパスを取得する必要があり、そのためのフック usePathname() が Next.js で用意されています。しかし、これは Server Component では利用できません。'use client' と書くことにより Client Component にしています。

Client Component だと Static Exports したときに HTML として書き出せないのではないか?という心配は無用です。

With Static Rendering, both Server and Client Components can be prerendered on the server at build time.

https://nextjs.org/docs/app/building-your-application/rendering#static-rendering

感想

いかがでしたか?

Gatsby なら gatsby-plugin-react-i18next plugin を入れて以下のように config を書くだけで実現できるのですが、Next.js の Static Exports ではこううまくはいかないのが現実だなあと感じさせられました。

    {
      resolve: `gatsby-plugin-react-i18next`,
      options: {
        localeJsonSourceName: `locale`,
        languages: [`ja`, `en`],
        defaultLanguage: `ja`,
        siteUrl,
      }
    },

まあ Vercel 使ってねってことなんでしょうけど。i18n 要件のある静的サイトを作るのに Next.js を選択するのはまだまだ厳しいなと思いました。良いやり方知っている人いたらぜひ教えてください。

そして全く同じような境遇の人のエントリを発見しました……

zenn.dev