12
7

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 App Routerで多言語対応を最短で実装する — next-intl実践ガイド

12
Posted at

Next.jsプロジェクトに多言語対応を導入する — next-intlで実装するi18nの実践ガイド

image.png

はじめに

グローバル展開を視野に入れたプロジェクトや、日英・日中など複数言語を扱うサービスを作るとき、i18n(国際化)の仕組みをどう設計するかは意外と悩みどころです。

Next.jsにはi18n向けのライブラリがいくつかありますが、next-intl を選ぶ理由は明確です。ビルド時の複雑な設定が不要で、App Routerのパラダイム(Server/Clientの分離)に自然に溶け込み、型安全性も備えています。react-i18next のようなライブラリと比べると、Server Componentsとの親和性において現時点で最もバランスの取れた選択肢です。

この記事では、next-intlのセットアップから実務でよく登場するケースまでを順番に説明します。


前提

  • Next.js 14以上(App Router)
  • TypeScript使用
  • next-intl v4以上

セットアップ

インストール

npm install next-intl

ディレクトリ構成

src/
  app/
    [locale]/
      layout.tsx
      page.tsx
  i18n/
    routing.ts
    navigation.ts   ← localeを意識したLink・routerをここで定義
    request.ts
  messages/
    ja.json
    en.json
  middleware.ts     ← Next.js 16以上は proxy.ts

[locale] という動的セグメントで言語ごとにルーティングを分けるのがnext-intlの基本的なアプローチです。/ja/about/en/about のようなURLになります。


ステップ1: ルーティング設定

// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing'

export const routing = defineRouting({
  locales: ['ja', 'en'],
  defaultLocale: 'ja',
})

defaultLocale に設定した言語は、URLプレフィックスなしでもアクセスできます(/about で日本語、/en/about で英語)。この動作は後述のmiddlewareで制御されます。


ステップ2: ナビゲーションユーティリティの作成

next-intlが提供する LinkuseRouter を使うと、localeプレフィックスを自動で付与してくれます。next/linknext/navigation の代わりにここからimportするのが基本です。

// src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'

export const { Link, redirect, usePathname, useRouter } =
  createNavigation(routing)

セットアップの最初に定義しておくことで、後続のすべてのコンポーネントでこのユーティリティを使えるようになります。


ステップ3: ミドルウェア(プロキシ)の設定

// Next.js 15以下: src/middleware.ts
// Next.js 16以上: src/proxy.ts(関数名もproxyに変更)
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'

export default createMiddleware(routing)

export const config = {
  // trpc・APIルート・静的ファイルを対象外にする
  matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)',
}

Next.js 16以上を使う場合: middleware.ts は非推奨になり proxy.ts に改名されました。ファイル名に加えて、export する関数名も proxy に変更する必要があります。

// src/proxy.ts
import type { NextRequest } from 'next/server'
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'

const intlProxy = createMiddleware(routing)

export function proxy(request: NextRequest) {
  return intlProxy(request)
}

export const config = {
  matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)',
}

ステップ4: リクエスト設定

// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server'
import { hasLocale } from 'next-intl'
import { routing } from './routing'

export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale
  const locale = hasLocale(routing.locales, requested)
    ? requested
    : routing.defaultLocale

  return {
    locale, // next-intl v4から必須。省略すると "Unable to find next-intl locale" エラーになる
    messages: (await import(`../../messages/${locale}.json`)).default,
  }
})

hasLocale を使うことで as any を使わずに型安全にフォールバック処理を書けます。無効なlocaleが渡された場合は defaultLocale に自動的に切り替わります。


ステップ5: レイアウトの設定

// src/app/[locale]/layout.tsx
import { NextIntlClientProvider, hasLocale } from 'next-intl'
import { notFound } from 'next/navigation'
import { routing } from '@/i18n/routing'

type Props = {
  children: React.ReactNode
  params: Promise<{ locale: string }>
}

export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params

  if (!hasLocale(routing.locales, locale)) {
    notFound()
  }

  return (
    <html lang={locale}>
      <body>
        {/* v4から必須: Client Componentsがnext-intlを使うために必要 */}
        <NextIntlClientProvider>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  )
}

NextIntlClientProvidernext-intl v4から必須 です。これがないと、Client Components内で useTranslations などを呼んでもメッセージが参照できません。v4ではmessagesの受け渡しは自動で行われるため、props として渡す必要はありません。

また params の型が Promise<{ locale: string }> になっている点に注意してください。Next.js 15以降の仕様変更で、params は非同期で受け取る必要があります。


ステップ6: 翻訳ファイルの作成

// messages/ja.json
{
  "common": {
    "loading": "読み込み中...",
    "error": "エラーが発生しました",
    "save": "保存",
    "cancel": "キャンセル"
  },
  "nav": {
    "home": "ホーム",
    "about": "会社概要",
    "contact": "お問い合わせ"
  },
  "home": {
    "title": "ようこそ",
    "description": "このサービスについての説明文をここに書きます。"
  }
}
// messages/en.json
{
  "common": {
    "loading": "Loading...",
    "error": "An error occurred",
    "save": "Save",
    "cancel": "Cancel"
  },
  "nav": {
    "home": "Home",
    "about": "About",
    "contact": "Contact"
  },
  "home": {
    "title": "Welcome",
    "description": "Here is a description of this service."
  }
}

ケース1: Server Componentで翻訳を使う

Server Componentでは getTranslations を使います。非同期関数です。

// src/app/[locale]/page.tsx
import { getTranslations } from 'next-intl/server'

export default async function HomePage() {
  const t = await getTranslations('home')

  return (
    <main>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
    </main>
  )
}

複数のネームスペースを同時に使う場合:

export default async function HomePage() {
  const tHome   = await getTranslations('home')
  const tCommon = await getTranslations('common')

  return (
    <main>
      <h1>{tHome('title')}</h1>
      <button>{tCommon('save')}</button>
    </main>
  )
}

ケース2: Client Componentで翻訳を使う

Client Componentでは useTranslations を使います。同期hookです。

'use client'

import { useTranslations } from 'next-intl'

export function SaveButton() {
  const t = useTranslations('common')

  return (
    <button onClick={() => console.log('saved')}>
      {t('save')}
    </button>
  )
}

useTranslations が動くのは、親に NextIntlClientProvider が存在するからです。レイアウトで設定したProviderがここで効いています。


ケース3: 変数の埋め込みと複数形

翻訳テキストに動的な値を埋め込む場合:

// messages/ja.json
{
  "profile": {
    "greeting": "{name}さん、こんにちは",
    "itemCount": "{count, plural, =0 {アイテムなし} =1 {1件のアイテム} other {#件のアイテム}}"
  }
}
const t = useTranslations('profile')

// 変数の埋め込み
t('greeting', { name: '田中' })
// → "田中さん、こんにちは"

// 複数形
t('itemCount', { count: 0 })  // → "アイテムなし"
t('itemCount', { count: 1 })  // → "1件のアイテム"
t('itemCount', { count: 5 })  // → "5件のアイテム"

plural の構文はICU Message Format(国際化の標準フォーマット)に基づいています。言語ごとに複数形のルールが異なるため、この仕組みを使うとlocaleに合わせた表現が自動的に適用されます。


ケース4: 言語切り替えコンポーネント

ステップ2で作成した @/i18n/navigationuseRouterusePathname を使います。next/navigation のものとは異なり、localeを意識した遷移が簡潔に書けます。

'use client'

import { useLocale } from 'next-intl'
import { useRouter, usePathname } from '@/i18n/navigation'
import { routing } from '@/i18n/routing'

export function LocaleSwitcher() {
  const locale   = useLocale()
  const router   = useRouter()
  const pathname = usePathname() // localeプレフィックスを除いたパスが得られる

  const handleChange = (nextLocale: string) => {
    // pathnameはlocaleプレフィックスなしのパス(例: "/about")
    // routerがlocaleを自動で付与して遷移してくれる
    router.replace(pathname, { locale: nextLocale })
  }

  return (
    <select value={locale} onChange={(e) => handleChange(e.target.value)}>
      {routing.locales.map((loc) => (
        <option key={loc} value={loc}>
          {loc === 'ja' ? '日本語' : 'English'}
        </option>
      ))}
    </select>
  )
}

next/navigationusePathname/ja/about のようにlocaleプレフィックスを含んだパスを返しますが、@/i18n/navigationusePathname/about のようにlocaleを除いたパスを返します。そのため、router.replace(pathname, { locale: nextLocale }) と書くだけで正しくlocaleが切り替わります。URLを手動で分割・組み立てする必要はありません。


ケース5: localeを意識したLinkナビゲーション

@/i18n/navigation からimportした Link は、localeプレフィックスを自動付与します。

import { Link } from '@/i18n/navigation'

export function Nav() {
  return (
    <nav>
      <Link href="/">ホーム</Link>
      <Link href="/about">会社概要</Link>
      {/* 別のlocaleへのリンクはlocale propで指定 */}
      <Link href="/about" locale="en">View in English</Link>
    </nav>
  )
}

next/linkLink を使ってしまうとlocaleプレフィックスが自動付与されないため、必ず @/i18n/navigation からimportするようにしましょう。


ケース6: ページのメタデータをlocaleごとに設定する

// src/app/[locale]/about/page.tsx
import { getTranslations } from 'next-intl/server'
import type { Metadata } from 'next'

type Props = {
  params: Promise<{ locale: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { locale } = await params
  const t = await getTranslations({ locale, namespace: 'about' })

  return {
    title: t('metaTitle'),
    description: t('metaDescription'),
  }
}

export default async function AboutPage() {
  const t = await getTranslations('about')
  return <h1>{t('title')}</h1>
}
// messages/ja.json に追加
{
  "about": {
    "metaTitle": "会社概要 | MyApp",
    "metaDescription": "私たちのチームとミッションについて",
    "title": "会社概要"
  }
}

ケース7: 型安全なメッセージキー

next-intlはTypeScriptと組み合わせることで、存在しないキーを参照したときにコンパイルエラーを出せます。

// src/global.d.ts
import en from '../messages/en.json'

type Messages = typeof en

declare global {
  interface IntlMessages extends Messages {}
}

この設定をすると、t('存在しないキー') と書いた時点でエディタがエラーを表示してくれます。翻訳キーのタイポを実行前に検出できるため、翻訳ファイルの規模が大きくなるほど恩恵が大きくなります。


まとめ

ユースケース 使うもの
Server Componentで翻訳 getTranslations(非同期)
Client Componentで翻訳 useTranslations(hook)
変数の埋め込み・複数形 ICU Message Format
言語切り替え @/i18n/navigationuseRouter + usePathname
locale対応のLink @/i18n/navigationLink
ページのメタデータ generateMetadata + getTranslations
型安全なキー参照 global.d.ts でMessages型を宣言

next-intlはApp Routerを前提に設計されているため、Server ComponentsとClient Componentsの両方で自然に使えます。最初のセットアップに少し手間がかかりますが、一度整えてしまえばあとは翻訳ファイルを追加するだけで対応言語を増やしていけます。

多言語対応を後から追加しようとすると改修コストが大きくなりがちです。グローバル展開の可能性が少しでもあるなら、早い段階でi18nの仕組みを入れておくことをおすすめします。


よくある落とし穴

NextIntlClientProvider を追加し忘れる
next-intl v4から必須になりました。レイアウトに追加していないと、Client Components内で useTranslations が正しく動きません。

next/navigationLinkuseRouter を使ってしまう
next/linknext/navigation のものを使うと、localeプレフィックスが自動付与されません。必ず @/i18n/navigation からimportしましょう。

middleware.ts vs proxy.ts
Next.js 16以上では proxy.ts が正しいファイル名です。middleware.ts はまだ動きますが非推奨扱いで警告が出ます。

locale の return を省略する
next-intl v4では getRequestConfig の返り値に locale が必須です。省略すると "Unable to find next-intl locale" エラーになります。

12
7
0

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
12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?