18waysブログ

Next.jsアプリに複数の言語を追加する方法

既存のNext.jsアプリに、SEOを損なわず、ゼロから作り直すこともなく、多言語i18nとローカライズを追加する方法。

I18n(国際化)、l10n(地域化)、多言語対応……呼び方は何であれ、最終的には Next.js アプリを 1 つ以上の言語に対応させる必要があります。

難しいのは 1 文を翻訳することではありません。SEO を損なわず、コードベースをぐちゃぐちゃにせず、しかも保守負担まで抱え込まずに、実際の Next.js アプリへ複数言語を追加することです。

コードをすぐに見たいなら、18ways-next の GitHub サンプル をチェックしてください。

まずインフラを整える

まずは何よりも、パッケージをインストールする必要があります。18ways のライブラリをセットアップしますが、プロジェクトがシンプルで静的コンテンツしかないなら、i18next のようなツールもよく機能します。

bash
npm install @18ways/next @18ways/react

設定ファイルを作成します:

js
// 18ways.config.js
module.exports = {
  apiKey: 'pk_dummy_demo_token',
  baseLocale: 'en-GB',
  router: 'app', // 'app', or 'path' depending on which Next.js router you are using
};

Next.js の設定をラップします:

js
// next.config.js
const { withWays } = require('@18ways/next/config');
 
const nextConfig = {
  /*
   * your normal Next.js config here
   */ 
};
 
module.exports = withWays(nextConfig);

/ が適切なロケールへリダイレクトできるよう、ルートプロキシを追加します:

js
// proxy.js
export { default, config } from '@18ways/next/proxy';

次に、ローカライズされたレイアウトを追加します:

jsx
// app/layout.jsx
import './styles.css';
import { WaysRoot } from '@18ways/next/server';
 
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className="next-demo-body">
        <WaysRoot>{children}</WaysRoot>
      </body>
    </html>
  );
}

これで完了です! これで次のものが手に入りました:

最初のページを翻訳する

これで最初のページを翻訳できるようになります。i18next のようなツールでは、すべてのテキストを翻訳キーに分解し、コード内でそれらを参照する必要があります。実際にどうなるかは、選んだライブラリによって異なります。

18ways を使っているなら、翻訳したいテキストを <T> コンポーネントで囲むだけでかまいません:

jsx
// src/components/MyExampleComponent.jsx
import { useT, T } from '@18ways/react';
 
export default function MyExampleComponent() {
  const t = useT();
 
  return (
    <div>
      <T>Hello world!</T>
      <img
        src="https://example.com/image.png"
        alt={t('Example image')}
      />
    </div>
  );
}

たいていは、ユーザーが言語を切り替えられるようにしたいはずです:

jsx
// src/components/Footer.jsx
import { T, LanguageSwitcher } from '@18ways/react';
 
export default function Footer() {
  return (
    <footer>
      <T>My footer content</T>
      <LanguageSwitcher />
    </footer>
  );
}

落とし穴に注意

配線が整えば、こうしたプロジェクトがうまくいかなくなる道はいくつかあります。

サーバーサイドレンダリング

サーバーサイドレンダリング(SSR)により、Next.js アプリで HTML を事前レンダリングしてサーバーに出力できます。これは SEO のためにも重要ですし、ユーザーに不適切なコンテンツや誤った言語が一瞬表示されるのを防ぐためにも重要です。

i18next のようなライブラリを使っている場合、サーバーサイドレンダリング中に翻訳が読み込まれ、反映されていることに細心の注意を払う必要があります。ページの view-source:、たとえば view-source:http://localhost:3000/ を確認することでテストできます。本番環境でも、実際の本番ビルドで動作することを確認するために、これをチェックしてください。

18ways を使っているなら、その心配は不要です。すべて自動で処理されます。

翻訳キー

18ways を使っているなら、この点を気にする必要はまったくありません。18ways では翻訳キーは不要なので、通常どおりテキストをそのまま置いておけます。

多くの i18n システムでは、コードを翻訳キーに分解する必要があります。これらのキーの付け方には注意が必要です。

悪いキーは曖昧です:

js
// bad-keys.en-GB.js
module.exports = {
  title: 'Continue',
  button: 'Pay now',
  label: 'Home',
};

それらのキーからは、翻訳者にも開発者にも、そのテキストがどこに表示されるのかほとんど分かりません。

よりよいキーには文脈を含める:

js
// better-keys.en-GB.js
module.exports = {
  'checkout.payment.primaryButton': 'Pay now',
  'checkout.payment.stepTitle': 'Complete your payment',
  'account.sidebar.homeLink': 'Home',
};

動的にキーを生成するのも避けましょう:

js
// bad-dynamic-keys.js
const key = `checkout.${status}.${buttonType}`;
const translatedText = t(key);

これにより、翻訳キーを少しでも扱いやすくしようとする IDE ツールが壊れます。また、古い翻訳キーを検索して整理するのが非常に難しくなります。

js
// better-dynamic-keys.js
const keyMap = {
  success: {
    primary: t('checkout.success.primary'),
    default: t('checkout.success.default'),
  },
  error: {
    primary: t('checkout.error.primary'),
    default: t('checkout.error.default'),
  },
};
 
const translatedText = keyMap[status][buttonType];

これは DRY ではなくなりますが、翻訳キーが手に負えなくなるのを防ぐ最善の方法です。

さらに良いのは 18ways のようなツールを使うことです。そうすれば、翻訳キー自体が一切不要になります:

jsx
// app/[lang]/checkout/page.jsx
<T>Pay now</T>

ロケール検出

ロケール検出とは、ユーザーが明示的に切り替える前に、どの言語を表示するかを決めることです。

それは通常、次のようなものをいくつか組み合わせる形になります:

通常の Next.js ミドルウェアでは、こんな感じになることが多いです:

js
// middleware.js
 
/**
 * You don't need any of this if you're using 18ways 
 */
 
import { NextResponse } from 'next/server';
 
const acceptedLocales = ['en-GB', 'fr-FR'];
 
export function middleware(request) {
  const savedLocale =
    request.cookies.get('preferred-locale')?.value;
  const browserLocale =
    request.headers
      .get('accept-language')
      ?.split(',')[0] || 'en-GB';
 
  const locale = acceptedLocales.includes(savedLocale)
    ? savedLocale
    : acceptedLocales.includes(browserLocale)
      ? browserLocale
      : 'en-GB';
 
  if (request.nextUrl.pathname === '/') {
    return NextResponse.redirect(
      new URL(`/${locale}`, request.url)
    );
  }
 
  return NextResponse.next();
}

いくつかのライブラリには、程度の差こそあれこれを簡単にするヘルパーがあります。18ways では、初期の検出とリダイレクト層を自動で処理してくれます。

日付とロケール固有のプリミティブ

ロケールが違えば、日付・数値・通貨の表記も異なります。

たとえば:

素の JavaScript では、Intl を使って自分で処理します:

js
// formatting-dates-and-currency.js
const myLocale = getCurrentLocale(); // depends on your lib
 
const someTimestamp = new Date('2026-04-13T09:00:00Z');
const dateLabel = new Intl.DateTimeFormat('fr-FR', {
  dateStyle: 'long',
}).format(someTimestamp);
 
const someMoney = {
  amount: 1999,
  currency: 'EUR',
};
const moneyLabel = new Intl.NumberFormat('de-DE', {
  style: 'currency',
  currency: someMoney.currency,
}).format(someMoney.amount);
 
const translatedText = t(
  'my.translation.key',
  { dateLabel, moneyLabel }
);

18ways を使っているなら、ここも自動で処理されます:

jsx
const someTimestamp = new Date('2026-04-13T09:00:00Z');
const someMoney = {
  amount: 1999,
  currency: 'EUR',
};
 
<T>My text with {{ someTimestamp }} and {{ someMoney }}</T>

文字列をつなぐ

断片をつなぎ合わせて翻訳済み UI を作らないでください:

jsx
// bad-string-joining.jsx
const clickHereText = t('click.here');
const toGetStartedText = t('to.get.started')
<p><a href="#">{clickHereText}</a> {toGetStartedText}.</p>

これは複数言語で壊れます。

英語では「👉👉Click here👈👈 to get started」と言うかもしれませんが、フランス語では「Pour commencer, 👉👉cliquez ici👈👈」のほうが自然です。日本語のように「始めるには👉👉こちら👈👈をクリックしてください」のように、前後の両方に語が必要な言語もあります。

文の構造は変わることがあるので、分割してしまうと、よい翻訳がずっと難しくなります。

文全体のほうが翻訳しやすいのは、翻訳者が単語の順序を自然に入れ替えられ、意味を一つのまとまりとして把握できるからです。

このように JSX を翻訳しているなら:

jsx
// rich-text-message.jsx
<p>
  Something <strong>that we want to be bold</strong>
</p>

これはライブラリごとにかなり扱いが異なるため、どう管理するかは i18n ライブラリを参照する必要があります。

18ways を使っているなら、通常どおり JSX ブロック全体をそのまま翻訳できます。

jsx
// rich-text-message.jsx
<p>
  <T>Something <strong>that we want to be bold</strong></T>
</p>

変数

変数を使えば、文を丸ごと保ちながら実行時の値を差し込めます。

ほとんどの i18n ライブラリは、何らかの形でこれをサポートしています。18ways では:

jsx
// app/[lang]/page.jsx
<T>Hello {{ name: 'Ada' }}</T>

そのパターンがうまく機能するのは次のような場合です:

文は読みやすいまま、値は明示的なままです。

変数自体も翻訳する必要がある場合は、必ず t(...) で包むようにしてください:

jsx
const animal = t('dog');
<T>Favourite animal: {{ animal }}</T>

複数形

複数形のルールは言語によって異なります。英語には単数形しかありません(1 year2 years など)。ポーランド語には複数の複数形があります(1 rok2 lata5 lat)。日本語には複数形がまったくありません(1 年2 年3 年)。

ほとんどの i18n ライブラリでは、複数形処理のために ICU 風の構文を指定できます:

jsx
// app/[lang]/page.jsx
<T>
  {{
    unreadCount,
    format:
      'plural, =0{No unread messages} =1{One unread message} other{{unreadCount} unread messages}',
  }}
</T>

上記の方法は 18ways では 18通りに対応しますが、多くの場合は単にこれで十分です:

jsx
// app/[lang]/page.jsx
<T>{{ unreadCount }} unread messages</T>

18ways が複数形を処理してくれます!

今すぐ始める

Next.js アプリに複数言語を追加するのに、リライト案件にする必要はありません。

18ways のようなソリューションを使っていないなら、ルーティングと SSR を正しく設定したうえで、コピーを翻訳キーへ移し替えていき、よくある i18n の落とし穴を避けるようにしてください。

18ways を使っているなら、このあたりはすべて自動で処理されるので、テキストを <T> ブロックで囲み始めるだけで大丈夫です!

言語を変更中