18ways ブログ

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

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

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

難しいのは、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 ライブラリごとに扱いがかなり異なるため、どう管理するかは各 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 つしかありません(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 でも使えますが、多くの場合、次のようにするだけでも大丈夫です:

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

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

今すぐ始める

Next.js アプリに複数言語を追加することは、大規模な書き換え作業である必要はありません。

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

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