🐈

next-intl で顔文字サイトを多言語化した設計メモ

に公開

はじめに

顔文字屋という顔文字検索サイトを運営しています。525カテゴリー、数千種類の顔文字をワンクリックでコピーできるサービスです。

このサイトは Next.js 14 (App Router) で構築しており、多言語対応には next-intl を採用しました。この記事では、実際に運用して気づいた設計上のポイントや判断の背景を共有します。

なぜ next-intl を選んだか

Next.js の i18n ライブラリは複数ありますが、App Router との相性で絞ると選択肢は限られます。

ライブラリ App Router 対応 Server Components Edge Runtime
next-intl
next-i18next △(Pages Router 向け) × ×
react-intl
lingui

next-intl を選んだ決め手は3つです:

  1. Server Components でそのまま使えるgetTranslations() を await するだけ
  2. Edge Runtime 互換 — Cloudflare Pages にデプロイしているので必須条件
  3. ルーティング統合[locale] セグメントとの連携が自然

ディレクトリ構成

app/
└── [locale]/           # 言語別ルート
    ├── layout.tsx      # NextIntlClientProvider を配置
    ├── (default)/
    │   ├── page.tsx    # トップページ
    │   └── [slug]/     # 動的カテゴリページ(525ページ)
    └── ...

i18n/
├── locale.ts           # 対応言語の定義
├── routing.ts          # ルーティング設定
├── request.ts          # サーバー側の言語解決
└── messages/
    ├── ja.json         # 日本語(デフォルト)
    ├── en.json         # 英語
    └── zh.json         # 中国語(繁体字)

ポイントは [locale] を App Router の最上位セグメントに置くことです。これにより、すべてのページが自動的に言語パラメータを受け取れます。

locale 設定の設計

// i18n/locale.ts
export const locales = ["ja"];
export const defaultLocale = "ja";
export const localePrefix = "as-needed";

localePrefix: "as-needed" がこのサイトでは重要でした。

  • デフォルト言語(日本語)は /cute-kaomoji のようにプレフィックスなし
  • 英語は /en/cute-kaomoji のようにプレフィックス付き

顔文字は日本文化に根ざしたものなので、日本語ユーザーが最も自然に使えることを優先しました。URL に /ja/ が入ると冗長ですし、既存の被リンクやインデックスとの互換性も保てます。

Server Components での翻訳

App Router の Server Components で翻訳を使うのは非常にシンプルです:

// app/[locale]/(default)/page.tsx
import { getTranslations } from "next-intl/server";

export async function generateMetadata({ params }: { params: { locale: string } }) {
  const t = await getTranslations({ locale: params.locale, namespace: "metadata" });

  return {
    title: { absolute: t("home_title") },
    description: t("description"),
    keywords: t("keywords"),
  };
}

generateMetadata の中で翻訳関数を呼べるので、各言語ごとに適切な title / description を出力できます。SEO 的にはここが一番大事なところです。

翻訳キーの設計方針

翻訳ファイルのキー設計で悩んだ点が2つあります。

1. ネームスペースの粒度

最初はページごとにファイルを分けていましたが、525カテゴリーページのために525個の翻訳ファイルを作るのは現実的ではありません。

最終的にこうしました:

// ja.json
{
  "metadata": {
    "title": "顔文字屋",
    "home_title": "顔文字屋 - かわいい顔文字を無料コピペ【3000種類以上】",
    "description": "..."
  },
  "categories": {
    "naku": "泣く",
    "ureshii": "嬉しい",
    "okoru": "怒る"
    // ... 70+ カテゴリー
  },
  "kaomoji": {
    "copy_success": "コピーしました!",
    "search_placeholder": "顔文字を検索..."
  }
}

共通 UI 文言は1つの JSON にまとめ、カテゴリー名だけ categories ネームスペースで管理しています。カテゴリーページ固有のコンテンツ(title, description, FAQ)は別途 JSON 設定ファイルから読み込む方式にしました。

2. カテゴリー名のローマ字 vs 翻訳

URL スラッグは cry-kaomojicute-kaomoji のように英語ベースですが、翻訳キーはローマ字(naku, ureshii)にしました。

理由:日本語カテゴリーが起点だからです。「泣く」に対応する英語は「cry」ですが、翻訳キーを cry にすると日本語が後付けのように見えます。ローマ字をキーにすることで、「このサイトは日本語が主軸」という意図がコードからも読み取れます。

Middleware の処理

// middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";

const intlMiddleware = createMiddleware(routing);

export default function middleware(request: NextRequest) {
  const response = intlMiddleware(request);
  const { pathname } = request.nextUrl;
  const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}(?=\/|$)/, '') || '/';

  if (response) {
    response.headers.set('x-pathname', pathWithoutLocale);
    return response;
  }
  return NextResponse.next();
}

x-pathname ヘッダーを付与しているのは、layout.tsx で Canonical URL を生成するためです。next-intl の middleware が locale プレフィックスを処理した後の「素のパス」が必要になる場面があり、この方法で対応しています。

Canonical URL の多言語対応

// lib/canonical.ts
export function generateCanonicalUrl(pathname: string, locale: string): string {
  const baseUrl = 'https://www.kaomojiya.org';
  const cleanPath = pathname.startsWith('/') ? pathname.slice(1) : pathname;

  if (locale === 'ja') {
    return cleanPath ? `${baseUrl}/${cleanPath}` : baseUrl;
  }
  return cleanPath ? `${baseUrl}/${locale}/${cleanPath}` : `${baseUrl}/${locale}`;
}

デフォルト言語(ja)はプレフィックスなし、それ以外はプレフィックスありで Canonical を生成します。これにより、Google が言語ごとに正しいページを認識できます。

翻訳ファイルの動的インポート

525ページあるサイトで翻訳ファイルをバンドルに含めると、ファイルサイズが膨らみます。

// i18n/request.ts
export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;
  if (!locale || !routing.locales.includes(locale as any)) {
    locale = routing.defaultLocale;
  }

  try {
    const messages = (await import(`./messages/${locale.toLowerCase()}.json`)).default;
    return { locale, messages };
  } catch (e) {
    return {
      locale: "en",
      messages: (await import(`./messages/en.json`)).default,
    };
  }
});

import() で動的に読み込むことで、使われない言語のJSONがバンドルに入ることを防いでいます。Edge Runtime 環境では特にバンドルサイズの制約が厳しい(Cloudflare Workers は 25MB 制限)ので、この工夫は欠かせません。

Client Components での翻訳

コピーボタンのフィードバックなど、クライアント側でも翻訳が必要な場面があります:

"use client";
import { useTranslations } from "next-intl";

export function CopyButton({ kaomoji }: { kaomoji: string }) {
  const t = useTranslations("kaomoji");

  const handleCopy = async () => {
    await navigator.clipboard.writeText(kaomoji);
    toast(t("copy_success")); // 「コピーしました!」
  };

  return <button onClick={handleCopy}>{kaomoji}</button>;
}

NextIntlClientProvider を layout.tsx で配置しておけば、useTranslations フックがそのまま使えます。Server / Client の境界を意識せず同じ翻訳キーで統一できるのは楽です。

運用してみて感じたこと

良かった点

  • 段階的に言語を追加できるlocales 配列に言語コードを足すだけで新しい言語が有効になる
  • SEO への影響が小さい — デフォルト言語の URL が変わらないので、既存のインデックスを壊さない
  • 型安全 — TypeScript との相性が良く、存在しない翻訳キーを参照するとビルド時に気づける

注意点

  • 翻訳ファイルが肥大化しやすい — 525カテゴリーの名前だけで70行以上。JSON の分割戦略は早めに考えたほうが良い
  • 動的コンテンツの翻訳コスト — カテゴリーページの title / description / FAQ はカテゴリーごとに異なるため、翻訳量が膨大になる。機械翻訳 + 人手レビューの仕組みが必要
  • hreflang タグ — 複数言語を有効にしたら <link rel="alternate" hreflang="..."> の出力を忘れずに

まとめ

next-intl は Next.js App Router での多言語化において、現時点でもっとも実用的な選択肢だと感じています。特に Server Components / Edge Runtime との互換性は、他のライブラリにはない強みです。

顔文字のように「日本語が主軸だが、海外ユーザーにも届けたい」というサイトでは、localePrefix: "as-needed" による段階的な多言語展開が有効でした。

サイトはこちらです → 顔文字屋 - kaomojiya.org

Discussion