著者:
    作成:2026-08-29最終更新:2026-08-29

    Intlayerを使用してhtmxアプリケーションを翻訳する | 国際化(i18n)

    htmxはそれ自体のコンテンツをレンダリングしません。訪問者が読むすべてのラベルはサーバーが生成したHTMLであり、すべてのスワップは別のHTTPリクエストです。したがって、htmxアプリの国際化はサーバーの関心事です。ロケールは各リクエストで解決される必要があり、各フラグメントはそのロケールでレンダリングされる必要があります。

    Intlayerはバックエンド統合を通じてこれをカバーし、リクエストごとにロケールを検出し、HTMLを構築するハンドラーに対して宣言されたコンテンツを公開します。

    目次

    htmxアプリでのi18nの3つのルール

    A single page can trigger dozens of swaps. Each one is a fresh request with no memory of the page that issued it. If the locale lives in a variable set during the initial render, every fragment after it falls back to the default language.

    The Intlayer middleware resolves the locale from the request itself, so a fragment served at minute ten answers in the same language as the page served at minute zero.

    Two carriers work with htmx. A cookie (INTLAYER_LOCALE) is sent by the browser automatically on every request, including htmx ones. A header (x-intlayer-locale) can be attached to htmx requests with the hx-headers attribute. Both are read by default.

    フラグメントに挿入された翻訳値はマークアップです。他の動的な値と同じように、それをエスケープしてください。そうすれば、< を含む翻訳が、それが交換されるドキュメントを破壊することはありません。


    ステップバイステップガイド

    ide.intlayer.org

    Application Template を GitHub で参照してください。

    1. 依存関係をインストール

      intlayer とサーバーの統合をインストールします。

      bash
      npm install intlayer express-intlayer cookie-parser
      
      bash
      npm install intlayer fastify-intlayer @fastify/cookie @fastify/formbody
      
      bash
      npm install intlayer hono-intlayer
      
      bash
      npm install intlayer elysia-intlayer
      
      bash
      bun add intlayer elysia-intlayer
      
      ExpressとFastifyはそれぞれのcookieパーサーを通じてlocale cookieを読み取るため、これらと一緒にインストールする必要があります。HonoとElysiaはネイティブにcookieを解析します。

      htmx自体は単一のscriptタグであり、ステップ4で追加されます。

    2. プロジェクトの設定

      プロジェクトのルートにintlayer.config.tsを作成します:

      intlayer.config.ts
      import { Locales, type IntlayerConfig } from "intlayer";
      
      const config: IntlayerConfig = {
        internationalization: {
          locales: [Locales.ENGLISH, Locales.FRENCH, Locales.SPANISH, Locales.ARABIC],
          defaultLocale: Locales.ENGLISH,
        },
      };
      
      export default config;
      
      全オプションのリストについては、設定ドキュメントを参照してください。
    3. コンテンツを宣言する

      サーバーがレンダリングするすべてのラベルを宣言します。フラグメント内にのみ表示されるものも含めて:

      src/app.content.ts
      import { insert, t, type Dictionary } from "intlayer";
      
      const appContent = {
        key: "app",
        content: {
          pageTitle: "Intlayer + htmx",
      
          localeLabel: t({
            ja: "言語",
            en: "Language",
            fr: "Langue",
            es: "Idioma",
            ar: "اللغة",
          }),
      
          cartSummary: insert(
            t({
              ja: "カート内のアイテム: {{count}}",
              en: "Items in your cart: {{count}}",
              fr: "Articles dans votre panier : {{count}}",
              es: "Artículos en tu carrito: {{count}}",
              ar: "المنتجات في سلتك: {{count}}",
            })
          ),
      
          addItem: t({
            ja: "アイテムを追加",
            en: "Add an item",
            fr: "Ajouter un article",
            es: "Añadir un artículo",
            ar: "أضف منتجًا",
          }),
        },
      } satisfies Dictionary;
      
      export default appContent;
      
      コンテンツ宣言は contentDir 以下(デフォルトは ./src)のどこにでも配置でき、.content.{json,ts,tsx,js,jsx,mjs,cjs,md,mdx,yaml,yml} にマッチします。コンテンツ宣言ドキュメンテーションを参照してください。
    4. Intlayer ミドルウェアを登録する

      各リクエストのlocaleを解決し、ハンドラーに公開するミドルウェア。

      src/index.ts
      import cookieParser from "cookie-parser";
      import express from "express";
      import { intlayer } from "express-intlayer";
      
      const app = express();
      
      // Cookie parserが最初に実行される必要があります: `express-intlayer`は
      // `req.cookies`を通じてlocale cookieを読み込みます。
      app.use(cookieParser());
      app.use(express.urlencoded({ extended: false }));
      app.use(intlayer());
      

      解決されたlocaleはres.locals.localeにあります。

      src/index.ts
      typescript fileName="src/index.ts" codeFormat={["typescript", "esm"]}
      import cookie from "@fastify/cookie";
      import formbody from "@fastify/formbody";
      import Fastify from "fastify";
      import { intlayer } from "fastify-intlayer";
      
      const fastify = Fastify();
      
      await fastify.register(cookie);
      await fastify.register(formbody);
      await fastify.register(intlayer);
      

      解決されたロケールは req.intlayer.locale にあります。

      src/index.ts
      import { Hono } from "hono";
      import { intlayer } from "hono-intlayer";
      
      const app = new Hono();
      
      app.use("*", intlayer());
      

      解決されたロケールは c.get("locale") です。

      src/index.ts
      import { Elysia } from "elysia";
      import { intlayer } from "elysia-intlayer";
      
      const app = new Elysia().use(intlayer());
      

      解決済みロケールは、ルートコンテキストの intlayer!.locale です。

      デフォルトでは、ロケールは INTLAYER_LOCALE クッキーから取得され、次に x-intlayer-locale ヘッダー、その後 Accept-Language ネゴシエーションから取得されます。

    5. リクエストロケールでフラグメントをレンダリングする

      フラグメントレンダラーをロケールの純粋な関数として記述し、ミドルウェアが解決したロケールを渡します。明示的に渡すことで、フラグメントがどのサーバー上にあるかに関わらず、それを要求したリクエストに関連付けられます。

      src/views.ts
      import { currency, getIntlayer, type Locale } from "intlayer";
      
      const HTML_ENTITIES: Record<string, string> = {
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
      };
      
      /** 翻訳された値がマークアップから抜け出さないようにエスケープします。 */
      const escapeHtml = (value: string): string =>
        value.replace(
          /[&<>"']/g,
          (character) => HTML_ENTITIES[character] ?? character
        );
      
      export const renderCart = (locale: Locale, itemCount: number): string => {
        const content = getIntlayer("app", locale);
      
        return `<section id="cart">
        <p>${escapeHtml(String(content.cartSummary({ count: itemCount })))}</p>
        <p>${escapeHtml(currency(itemCount * 12.5, { locale, currency: "EUR" }))}</p>
        <button
          hx-post="/cart/items"
          hx-vals='{"itemCount": ${itemCount}}'
          hx-target="#cart"
          hx-swap="outerHTML"
        >${escapeHtml(String(content.addItem))}</button>
      </section>`;
      };
      

      ルートから提供する:

      src/index.ts
      app.post("/cart/items", (req, res) => {
        // リクエストボディからitemCountを取得し、デフォルト値は0
        const itemCount = Number(req.body?.itemCount ?? 0) + 1;
      
        // renderCart関数を使用してHTMLを生成し、レスポンスを返す
        res.type("html").send(renderCart(res.locals.locale, itemCount));
      });
      
      src/index.ts
      fastify.post("/cart/items", async (req, reply) => {
        // リクエストボディからitemCountを取得し、デフォルト値は0
        const itemCount =
          Number((req.body as { itemCount?: string })?.itemCount ?? 0) + 1;
      
        // renderCart関数を使用してHTMLを生成し、レスポンスを返す
        return reply
          .type("text/html")
          .send(renderCart(req.intlayer.locale, itemCount));
      });
      
      src/index.ts
      app.post("/cart/items", async (c) => {
        const body = await c.req.parseBody();
        const itemCount = Number(body["itemCount"] ?? 0) + 1;
      
        return c.html(renderCart(c.get("locale"), itemCount));
      });
      
      src/index.ts
      app.post("/cart/items", ({ body, intlayer }) => {
        const itemCount =
          Number((body as { itemCount?: string })?.itemCount ?? 0) + 1;
      
        return new Response(renderCart(intlayer!.locale, itemCount), {
          headers: { "content-type": "text/html" },
        });
      });
      

      同じフラグメントは、クッキーが fr であるビジターに対してはフランス語で、クッキーが ar であるビジターに対してはアラビア語で応答するようになりました。呼び出すマークアップに変更はありません。

    6. 最初のページを配信する

      <body> を単独でレンダリングして、ステップ 7 のロケール切り替え機能でそれ全体をスワップできるようにしてから、htmx を読み込むドキュメントでラップします:

      src/views.ts
      import { getHTMLTextDir, getIntlayer, type Locale } from "intlayer";
      
      export const renderBody = (locale: Locale, itemCount: number): string => {
        const content = getIntlayer("app", locale);
      
        return `<body lang="${locale}" dir="${getHTMLTextDir(locale)}">
        <main>
          <h1>${escapeHtml(String(content.pageTitle))}</h1>
          ${renderLocaleSwitcher(locale)}
          ${renderCart(locale, itemCount)}
        </main>
      </body>`;
      };
      
      export const renderPage = (locale: Locale, itemCount: number): string =>
        `<!doctype html>
      <html lang="${locale}" dir="${getHTMLTextDir(locale)}">
      <head>
        <meta charset="utf-8" />
        <title>${escapeHtml(String(getIntlayer("app", locale).pageTitle))}</title>
        <script src="https://unpkg.com/htmx.org@2.0.4"></script>
      </head>
      ${renderBody(locale, itemCount)}
      </html>`;
      

      getHTMLTextDir は、ロケールに対して ltrrtl、または auto を返します。これにより、アラビア語とヘブライ語が正しくレイアウトされるようになります。

    7. 言語を切り替える

      言語の切り替えは、他のリクエストと同様です。サーバーはこの選択をミドルウェアが読み込むクッキーに保存し、その後、ページを新しいロケールで再レンダリングして返します。

      <select>を使用してスイッチャーをレンダリングし、自身をpostして全体の<body>を置き換えるため、フラグメント周囲の静的ラベルも変更されます:

      src/views.ts
      import { getIntlayer, getLocaleName, type Locale, locales } from "intlayer";
      
      const renderLocaleSwitcher = (locale: Locale): string => {
        const content = getIntlayer("app", locale);
      
        const options = locales
          .map(
            (availableLocale: Locale) =>
              `<option value="${availableLocale}"${availableLocale === locale ? " selected" : ""}>${escapeHtml(getLocaleName(availableLocale, locale))}</option>`
          )
          .join("");
      
        return `<form>
        <label for="locale">${escapeHtml(String(content.localeLabel))}</label>
        <select
          id="locale"
          name="locale"
          hx-post="/locale"
          hx-trigger="change"
          hx-target="body"
          hx-swap="outerHTML"
        >${options}</select>
      </form>`;
      };
      
      getLocaleName(availableLocale, locale) は、現在表示されている言語で各言語を記述します。2番目の引数を渡さない場合は、代わりに各言語をそれ自身の言語で記述します。

      POSTを処理するには、値を検証し、cookieを設定し、新しいbodyを返します:

      src/index.ts
      import { isDeclaredLocale } from "intlayer";
      
      app.post("/locale", (req, res) => {
        // リクエストボディからロケールを取得
        const requestedLocale = String(req.body?.locale);
      
        // 宣言されたロケールかどうかを確認
        if (!isDeclaredLocale(requestedLocale)) {
          res.status(400).send("Unknown locale");
          return;
        }
      
        res.cookie("INTLAYER_LOCALE", requestedLocale, {
          sameSite: "lax",
          path: "/",
        });
        res.type("html").send(renderBody(requestedLocale, 0));
      });
      
      src/index.ts
      import { isDeclaredLocale } from "intlayer";
      
      fastify.post("/locale", async (req, reply) => {
        const requestedLocale = String((req.body as { locale?: string })?.locale);
      
        if (!isDeclaredLocale(requestedLocale)) {
          return reply.status(400).send("Unknown locale");
        }
      
        return reply
          .setCookie("INTLAYER_LOCALE", requestedLocale, {
            sameSite: "lax",
            path: "/",
          })
          .type("text/html")
          .send(renderBody(requestedLocale, 0));
      });
      
      src/index.ts
      import { setCookie } from "hono/cookie";
      import { isDeclaredLocale } from "intlayer";
      
      app.post("/locale", async (c) => {
        // ボディをパースしてrequestLocaleを取得
        const body = await c.req.parseBody();
        const requestedLocale = String(body["locale"]);
      
        // 宣言されたロケールかどうかをチェック
        if (!isDeclaredLocale(requestedLocale)) {
          return c.text("Unknown locale", 400);
        }
      
        // クッキーを設定
        setCookie(c, "INTLAYER_LOCALE", requestedLocale, {
          sameSite: "Lax",
          path: "/",
        });
        // HTMLレスポンスを返す
        return c.html(renderBody(requestedLocale, 0));
      });
      
      src/index.ts
      import { isDeclaredLocale } from "intlayer";
      
      app.post("/locale", ({ body, cookie, status }) => {
        const requestedLocale = String((body as { locale?: string })?.locale);
      
        if (!isDeclaredLocale(requestedLocale)) {
          return status(400, "Unknown locale");
        }
      
        cookie["INTLAYER_LOCALE"]!.set({
          value: requestedLocale,
          sameSite: "lax",
          path: "/",
        });
      
        return new Response(renderBody(requestedLocale, 0), {
          headers: { "content-type": "text/html" },
        });
      });
      
      isDeclaredLocale は任意の文字列をあなたが設定したロケールの1つに絞り込むため、予期しない値がレンダラーに到達することはありません。
    8. スワップ後に lang と dir を同期させた状態に保つ

      オプション

      swapは<body>を置き換えることができますが、その周りの<html>は置き換えられません。swappedされたbodyにlangdirをレンダリングし、headからroot要素に一度コピーして戻します:

      src/views.ts
      <script>
        document.addEventListener("htmx:afterSwap", () => {
          document.documentElement.lang = document.body.lang;
          document.documentElement.dir = document.body.dir;
        });
      </script>
      

      これがない場合、アラビア語への切り替えはbody内で右から左へレンダリングされますが、ドキュメントは前の言語を支援技術とクローラーに引き続き広告します。

    9. localeをクッキーの代わりにヘッダーとして送信する

      オプション

      Cookieが適切でない場合は、祖先要素のhx-headersを使用して、すべてのhtmxリクエストにlocaleを付与します。子孫はそれを継承します:

      html
      <body hx-headers='{"x-intlayer-locale": "fr"}'>
        ...
      </body>
      

      ミドルウェアはデフォルトでx-intlayer-localeを読み取ります。設定でキャリアの名前を変更できます:

      intlayer.config.ts
      import { Locales, type IntlayerConfig } from "intlayer";
      
      // Intlayerの設定
      const config: IntlayerConfig = {
        // ... その他の設定オプション
        routing: {
          storage: [
            { type: "header", name: "my-locale-header" },
            { type: "cookie", name: "my-locale-cookie" },
          ],
        },
      };
      
      export default config;
      

    TypeScriptの設定

    自動生成された型を含めることで、実行時に空の文字列になるのではなく、宣言されていないキーがコンパイルエラーになります。

    tsconfig.json
    {
      // ... 既存の TypeScript 設定
      "include": [
        // ... 既存の TypeScript 設定
        ".intlayer/**/*.ts", // 自動生成された型を含める
      ],
    }
    

    Git 設定

    Intlayer によって生成されたファイルを無視することをお勧めします:

    .gitignore
    # Intlayer によって生成されたファイルを無視
    .intlayer
    

    VS Code Extension

    Intlayer を使用した開発体験を向上させるために、公式の Intlayer VS Code Extension をインストールできます。

    VS Code Marketplace からインストール

    このエクステンションは以下を提供します:

    • 翻訳キーの自動補完
    • 欠落している翻訳のリアルタイムエラー検出
    • 翻訳済みコンテンツのインラインプレビュー
    • 翻訳を簡単に作成・更新するクイックアクション

    エクステンションの使用方法の詳細については、Intlayer VS Code エクステンションのドキュメントを参照してください。


    さらに進める

    さらに進めるには、CMSを使用してコンテンツを外部化できます。これにより、翻訳者はデプロイメントなしでコピーを変更できます。

    よくある質問

    フラグメントリクエストにロケールが含まれていなかったため。htmxリクエストはそれを発行したページから独立しているため、INTLAYER_LOCALE cookieまたはhx-headersで設定されたx-intlayer-localeヘッダーを通じて、各リクエストにロケールを含める必要があります。ExpressおよびFastifyでcookie parserがIntlayer middlewareの前に実行されていることを確認してください。そうでない場合、cookieは読み込まれず、すべてのリクエストがAccept-Languageにフォールバックします。

    それを渡してください。インテグレーションは解決されたロケール(res.locals.localereq.intlayer.localec.get("locale")intlayer!.locale)を公開しており、getIntlayerに渡すことで各レンダラーをロケールの純粋な関数にします。これはテストしやすく、サーバーを変更した場合でもフラグメントレンダラーをポータブルに保つことができます。

    いいえ。訪問者が見るものはすべてサーバーによって生成されるため、ブラウザで翻訳するものはありません。これはまた、htmxアプリのi18nのページ重量コストがほぼゼロである理由です。カタログがクライアントに送信されることはありません。

    ロケールプレフィックス(/fr/cart)の下でページを提供し、Cookie ではなくルートハンドラーのパスからロケールを読み取ります。これはフルページレンダリングの場合です。フラグメントは引き続き Cookie またはヘッダーを使用できます。configurationでルーティングオプションを、custom URL rewritesを参照してください。

    getHTMLTextDir(locale)ltrrtl、または auto を返します。初期レンダリングではドキュメントに設定し、ステップ 8 に示すようにスワップ後に再度適用します。CSS 論理プロパティ(margin-left ではなく margin-inline-start)を使用して、レイアウトが従うようにします。

    はい、テンプレート文字列に挿入するすべてのものに対して、他の動的値とまったく同じようにエスケープする必要があります。CMSまたは翻訳者から来るコンテンツは、あなたが制御するマークアップではありません。ステップ5は最小限のエスケーパーを示しています。