nino
  • ドキュメント
  • レジストリ
  • メンバーシップ

Command Palette

Search for a command to run...

スポンサー
メニュー

多言語対応

 

6分 21秒

既存のライブラリは重く、Cache との相性も悪いためスクラッチします。

多言語対応の仕組み

  1. 翻訳文と型を定義
  2. ブラウザからユーザーの言語を取得(初回のみ)
  3. 言語パスにリダイレクト(/ja, /en)
  4. URLに基づいて表示言語を切り替え
  5. 言語変更時はブラウザの Cookie に保存し、以降はCookieの値を使用

URLの言語階層('/ja', '/en')は Proxy や ライブラリ を使うことで隠蔽できます。このガイドでは隠蔽する方法を紹介します。

関連ファイル

最終的に以下のファイルで構成されます。

TypeScripti18n-provider.tsx# クライアントでの言語取得用プロバイダー
TypeScriptlocale-toggle.tsx# 表示言語の切り替えコンポーネント
TypeScriptlocale.ts# 言語の定義
TypeScriptrouting.ts# 言語パスに基づいてリダイレクトする関数
TypeScriptserver.ts# サーバーサイドでの言語取得とキャッシュ
TypeScriptutils.ts# 言語取得用のヘルパー関数
TypeScripten.ts# 英語の翻訳文
TypeScriptja.ts# 日本語の翻訳文
TypeScriptmessage.ts# 翻訳文の型定義

翻訳文の定義

まずデフォルト言語の翻訳文を定義します。

Loading...

次に、デフォルト言語をベースに言語の型を定義します。

Loading...

型を使用してその他の言語の翻訳文を定義します。型安全でメッセージを定義できます。

Loading...

以後、あらゆるテキストは翻訳文に定義したキーを使用して取得します。

言語の取得とリダイレクト

Proxy 用のルーティングヘルパーを作成します。このヘルパーはリダイレクトとパスの言語階層を隠蔽します。

Loading...
Loading...

Proxy のミドルウェアでこの関数を呼び出します。

Loading...

これにより、Cookie、もしくはブラウザの言語設定に応じて適切な言語パスにリダイレクトします。

翻訳テキストの使用

翻訳テキストを取得するためのヘルパー関数を作成します。

Loading...

レイアウト、画面で言語を取得し、キャッシュする

まず、すべての画面やレイアウトは /[locale] パス配下に配置します。

ルートレイアウト

ルートレイアウトは必要なので空で用意します、

Loading...

メインレイアウト

これが実質的なルートレイアウトになります。動的パラメーターを使用して言語を取得し、設定します。内部的に React Cache を使用するため、サーバーサイドでのレンダリング中に下位コンポーネントはキャッシュされた言語情報にアクセスできます。

Loading...

各画面

各画面も同様に動的パラメーターを使用して言語を取得し、設定します。Next.js では Layout と Page が並列で処理されるため、Page の方が先に処理されるケースに備えて Page 側でも言語情報をキャッシュする必要があります。

  • getMessage により既存の言語に応じた翻訳テキストを取得できます。
  • generateMetadata を使ってメタデータを生成します。
  • generateStaticParams を使って静的パラメーターを生成します。
Loading...

クライアント用プロバイダーの作成

クライアントで翻訳メッセージを使用するためのプロバイダーを作成します。

Loading...

次にメインレイアウトにプロバイダーを設置します。

Loading...

これによりクライアントコンポーネントから翻訳テキストを取得できます。

Loading...

表示言語の切り替え

切り替えコンポーネントを作成し、任意の場所で使用します。

Loading...
Discord で質問する
何か気になったこと、分からないことがあれば気軽に質問してください!
DiscordDiscordで質問する
nino

Developer

XXGitHubGitHubYouTubeYouTubeZennZennDiscordDiscord

    リンク

  • ドキュメント
  • レジストリ
  • アーキテクチャ

    ツール

  • フィード
  • ステータス

    ポリシー

  • 利用規約
  • プライバシーポリシー
  • 特定商取引法に基づく表示
ドキュメント
はじめに
  • Changelog
ガイド
  • Webアプリの環境構築
  • ニュース収集&AI要約
  • Turso のテーブル移行
  • 日時の管理
  • 中規模のリストをブラウザにキャッシュし、クライアントでフィルタする
  • Search Params Dialog
  • コードの整理
  • AGENTS.md
  • Better Auth
  • プロダクト開発ポリシー
  • 推奨ツール
  • Proxy.ts
  • 多言語対応
  • SWR
  • Cache Component
  • Next.js の課題、不具合
  • アプリ開発フロー
  • プロンプトガイド
  • CSS Tips
  • Font Family
  • セルフブランディング
  • セルフオンボーディング
  • オフィスツール
  • Email
  • Breadcrumb(パンくず)
  • 並べ替え
export default {
  TopPage: {
    title: "トップページ",
  },
};
import jaMessages from "@/messages/ja";

// 再帰的なキーパスの型を生成するヘルパー型
type NestedKeyOf<T> = T extends object
  ? {
      [K in keyof T]: K extends string
        ? T[K] extends object
          ? `${K}.${NestedKeyOf<T[K]>}` | K
          : K
        : never;
    }[keyof T]
  : never;

// ネストされたキーから値を取得する型
type NestedValueOf<T, K extends string> = K extends `${infer P}.${infer S}`
  ? P extends keyof T
    ? NestedValueOf<T[P], S>
    : never
  : K extends keyof T
    ? T[K]
    : never;

// 日本語メッセージから型を抽出
export type MessagesSchema = typeof jaMessages;
export type KeyOfMessages = keyof MessagesSchema;
export type NestedKeyOfMessages = NestedKeyOf<MessagesSchema>;
export type NestedValueOfMessages<K extends NestedKeyOfMessages> =
  NestedValueOf<MessagesSchema, K>;
import { MessagesSchema } from "@/types/message";

export default {
  TopPage: {
    title: "Top Page",
  },
} satisfies MessagesSchema;
pnpm add @formatjs/intl-localematcher
import { NextRequest, NextResponse } from "next/server";
import Negotiator from "negotiator";
import { match } from "@formatjs/intl-localematcher";
import { defaultLocale, Locale, locales } from "./locale";

export function handleLocaleRouting(request: NextRequest): NextResponse {
  const pathname = request.nextUrl.pathname;

  const hasLocale = locales.some((locale) => pathname.startsWith(`/${locale}`));

  if (hasLocale) {
    const trimReg = new RegExp(`^/(${locales.join("|")})`);
    const trimmedPathname = pathname.replace(trimReg, "");
    const targetLocale = pathname.match(trimReg)?.[0]?.slice(1);

    const response = NextResponse.redirect(
      new URL(trimmedPathname || "/", request.url)
    );
    response.cookies.set("locale", targetLocale || defaultLocale);
    return response;
  }

  const cookieLocale = request.cookies.get("locale")?.value;
  const negotiator = new Negotiator({
    headers: {
      "accept-language": request.headers.get("accept-language") || "",
    },
  });
  const languages = negotiator.languages();

  const locale = locales.includes(cookieLocale as Locale)
    ? cookieLocale
    : match(languages, locales, defaultLocale);

  const url = request.nextUrl.clone();
  url.pathname = `/${locale}${pathname}`;
  return NextResponse.rewrite(url);
}
import { handleLocaleRouting } from "@/lib/i18n/routing";

export async function proxy(request: NextRequest) {
  return handleLocaleRouting(request);
}
import "server-only";

import {
  MessagesSchema,
  NestedKeyOfMessages,
  NestedValueOfMessages,
} from "@/types/message";
import { cache } from "react";
import { defaultLocale, Locale, locales } from "./locale";
import {
  getMessageWithFallback as getMessageWithFallbackUtil,
  getNestedValue,
} from "./utils";

let _currentLocale: Locale = defaultLocale;

export const setCurrentLocale = (locale: string) => {
  if (!locales.includes(locale as Locale)) {
    throw new Error(`Invalid locale: ${locale}`);
  }

  _currentLocale = locale as Locale;
};

/**
 * paramsからlocaleを取得して設定する共通関数
 */
export const setCurrentLocaleFromParams = async (
  params: Promise<{ locale: string }>
) => {
  const { locale } = await params;

  if (!locales.includes(locale as Locale)) {
    throw new Error(`Invalid locale: ${locale}`);
  }

  setCurrentLocale(locale);
  return getCurrentLocale();
};

export const getCurrentLocale = cache(() => {
  return _currentLocale || defaultLocale;
});

export const getDictionary = async (): Promise<MessagesSchema> => {
  const locale = getCurrentLocale();
  const messages = await import(`../../messages/${locale}.ts`);
  return messages.default;
};

export const getMessage = async <K extends NestedKeyOfMessages>(
  key: K
): Promise<NestedValueOfMessages<K>> => {
  const dictionary = await getDictionary();
  return getNestedValue(dictionary, key);
};

export const getMessageWithFallback = async <K extends NestedKeyOfMessages>(
  key: K
): Promise<NestedValueOfMessages<K>> => {
  const dictionary = await getDictionary();
  const fallbackDictionary = await import(`../../messages/${defaultLocale}.ts`);
  return getMessageWithFallbackUtil(
    dictionary,
    fallbackDictionary.default,
    key
  );
};
export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return <>{children}</>;
}
import { Providers } from "@/components/providers";
import { Locale, locales } from "@/lib/i18n/locale";
import { setCurrentLocaleFromParams } from "@/lib/i18n/server";
import "@workspace/ui/globals.css";

export async function generateStaticParams() { 
  return locales.map((locale) => ({ locale })); 
} 

export default async function RootLayout({
  params,
  children,
}: LayoutProps<"/[locale]">) {
  const locale =await setCurrentLocaleFromParams(params); 

  return (
    <html lang={locale}>
      <body>
        {children}
      </body>
    </html>
  );
}
import { setCurrentLocaleFromParams } from "@/lib/i18n/server";
import { getMessage } from "@/lib/i18n/server";
import { locales } from "@/lib/i18n/locale";

export const generateMetadata = async ({ params }: PageProps<"/[locale]">) => {
  await setCurrentLocaleFromParams(params);
  const t = await getMessage("TopPage");
  return {
    title: t.title,
  };
};

export async function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

export default async function Page({
  params,
}: PageProps<"/[locale]">) {
  const locale = await setCurrentLocaleFromParams(params);
  const t = await getMessage("TopPage");

  return (
    <div>
      <h1>{t.title}</h1>
    </div>
  );
}
"use client";

import { defaultLocale, Locale } from "@/lib/i18n/locale";
import {
  MessagesSchema,
  NestedKeyOfMessages,
  NestedValueOfMessages,
} from "@/types/message";
import { getNestedValue } from "@/lib/i18n/utils";
import { createContext, use } from "react";

const I18nContext = createContext<{
  locale: Locale;
  dictionary: MessagesSchema;
}>({
  locale: defaultLocale,
  dictionary: {} as MessagesSchema,
});

export function I18nProvider({
  children,
  locale,
  dictionary,
}: {
  children: React.ReactNode;
  locale: Locale;
  dictionary: MessagesSchema;
}) {
  return <I18nContext value={{ locale, dictionary }}>{children}</I18nContext>;
}

export function useI18n() {
  return use(I18nContext);
}

export const useMessage = <K extends NestedKeyOfMessages>(
  key: K
): NestedValueOfMessages<K> => {
  const { dictionary } = useI18n();
  return getNestedValue(dictionary, key);
};
import { I18nProvider } from "@/components/i18n-provider";

<html lang={locale}>
  <body>
    <I18nProvider locale={locale}>{children}</I18nProvider>
  </body>
</html>
"use client";

import { useMessage } from "@/components/i18n-provider";

export function MyComponent() {
  const t = useMessage("TopPage");
  return <div>{t.title}</div>;
}
"use client";

import { Locale, locales } from "@/lib/i18n/locale";
import { Button } from "@workspace/ui/components/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { Languages } from "lucide-react";
import { usePathname } from "next/navigation";
import { useMessage, useI18n } from "./i18n-provider";

export default function LocaleToggle() {
  const pathname = usePathname();
  const { locale } = useI18n();
  const t = useMessage("Language");

  const handleChange = (nextLocale: Locale) => {
    location.href = `/${nextLocale}${pathname}`;
  };

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon">
          <Languages />
          <span className="sr-only">{locale === "ja" ? t.ja : t.en}</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuRadioGroup
          value={locale}
          onValueChange={(value) => handleChange(value as Locale)}
        >
          {locales.map((locale) => (
            <DropdownMenuRadioItem key={locale} value={locale}>
              {t[locale]}
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}