Auteur:
    Création:2026-08-29Dernière mise à jour:2026-08-29

    Traduisez votre application htmx en utilisant Intlayer | Internationalization (i18n)

    htmx ne rend aucun contenu de sa propre initiative. Chaque libellé qu'un visiteur lit est du HTML produit par votre serveur, et chaque swap est une requête HTTP distincte. L'internationalisation d'une app htmx est donc une préoccupation serveur : la locale doit être résolue à chaque requête, et chaque fragment doit être rendu dans cette locale.

    Intlayer couvre cela à travers ses intégrations backend, qui détectent la locale par requête et exposent votre contenu déclaré au handler qui construit le HTML.

    Table des matières

    Les trois règles de l'i18n dans une app htmx

    Une seule page peut déclencher des dizaines d'échanges. Chacun est une demande nouvelle sans mémoire de la page qui l'a émise. Si la locale réside dans une variable définie lors du rendu initial, chaque fragment après celui-ci revient à la langue par défaut.

    Le middleware Intlayer résout la locale à partir de la demande elle-même, de sorte qu'un fragment servi à la minute dix répond dans la même langue que la page servie à la minute zéro.

    Deux porteurs fonctionnent avec htmx. Un cookie (INTLAYER_LOCALE) est envoyé automatiquement par le navigateur à chaque demande, y compris les demandes htmx. Un en-tête (x-intlayer-locale) peut être attaché aux demandes htmx avec l'attribut hx-headers. Les deux sont lus par défaut.

    Une valeur traduite interpolée dans un fragment est du markup. Échappez-la, exactement comme vous le feriez pour toute autre valeur dynamique, afin qu'une traduction contenant < ne puisse pas casser le document dans lequel elle est échangée.


    Guide Étape par Étape

    ide.intlayer.org

    Voir Modèle d'Application sur GitHub.

    1. Installer les Dépendances

      Installez intlayer plus l'intégration pour votre serveur.

      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
      
      Express et Fastify lisent le cookie de locale via leurs propres parseurs de cookies, donc ceux-ci doivent être installés parallèlement. Hono et Elysia analysent les cookies nativement.

      htmx lui-même est une seule balise de script, ajoutée à l'étape 4.

    2. Configuration de votre projet

      Créez un intlayer.config.ts à la racine de votre projet :

      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;
      
      Pour la liste complète des options, voir la documentation de configuration.
    3. Déclarez Votre Contenu

      Déclarez chaque étiquette que le serveur restituera, y compris celles qui n'apparaissent que dans un fragment :

      src/app.content.ts
      import { insert, t, type Dictionary } from "intlayer";
      
      const appContent = {
        key: "app",
        content: {
          pageTitle: "Intlayer + htmx",
      
          localeLabel: t({
            fr: "Langue",
            en: "Language",
            es: "Idioma",
            ar: "اللغة",
          }),
      
          cartSummary: insert(
            t({
              fr: "Articles dans votre panier : {{count}}",
              en: "Items in your cart: {{count}}",
              es: "Artículos en tu carrito: {{count}}",
              ar: "المنتجات في سلتك: {{count}}",
            })
          ),
      
          addItem: t({
            fr: "Ajouter un article",
            en: "Add an item",
            es: "Añadir un artículo",
            ar: "أضف منتجًا",
          }),
        },
      } satisfies Dictionary;
      
      export default appContent;
      
      Les déclarations de contenu peuvent se trouver n'importe où sous contentDir (par défaut ./src) et correspondre à .content.{json,ts,tsx,js,jsx,mjs,cjs,md,mdx,yaml,yml}. Consultez la documentation de déclaration de contenu.
    4. Enregistrer le middleware Intlayer

      Le middleware résout la locale de chaque requête et l'expose à vos handlers.

      src/index.ts
      import cookieParser from "cookie-parser";
      import express from "express";
      import { intlayer } from "express-intlayer";
      
      const app = express();
      
      // Le cookie parser doit s'exécuter en premier : `express-intlayer` lit la locale
      // du cookie via `req.cookies`.
      app.use(cookieParser());
      app.use(express.urlencoded({ extended: false }));
      app.use(intlayer());
      

      La locale résolue se trouve sur res.locals.locale.

      src/index.ts
      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);
      

      La locale résolue est sur req.intlayer.locale.

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

      La locale résolue est c.get("locale").

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

      La locale résolue est intlayer!.locale sur le contexte de la route.

      Par défaut, la locale est extraite du cookie INTLAYER_LOCALE, puis de l'en-tête x-intlayer-locale, puis de la négociation Accept-Language.

    5. Rendre des fragments avec la locale de la requête

      Écrivez vos renderers de fragment comme des fonctions pures d'une locale, et passez la locale que le middleware a résolu. La passer explicitement lie un fragment à la requête qui l'a demandé, quel que soit le serveur sur lequel vous êtes.

      src/views.ts
      import { currency, getIntlayer, type Locale } from "intlayer";
      
      const HTML_ENTITIES: Record<string, string> = {
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
      };
      
      /** Échappe une valeur traduite pour qu'elle ne puisse pas s'échapper du markup. */
      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>`;
      };
      

      Le servir à partir d'une route :

      src/index.ts
      app.post("/cart/items", (req, res) => {
        const itemCount = Number(req.body?.itemCount ?? 0) + 1;
      
        res.type("html").send(renderCart(res.locals.locale, itemCount));
      });
      
      src/index.ts
      fastify.post("/cart/items", async (req, reply) => {
        const itemCount =
          Number((req.body as { itemCount?: string })?.itemCount ?? 0) + 1;
      
        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" },
        });
      });
      

      Le même fragment répond maintenant en français pour un visiteur dont le cookie indique fr, et en arabe pour celui dont le cookie indique ar, sans aucun changement au markup appelant.

    6. Servir la première page

      Rendu du <body> seul, de sorte que le commutateur de locale à l'étape 7 puisse le remplacer entièrement, puis envelopper-le dans le document qui charge htmx :

      src/views.ts
      import { getHTMLTextDir, getIntlayer, type Locale } from "intlayer";
      
      export const renderBody = (locale: Locale, itemCount: number): string => {
        // Récupère le contenu internationalisé pour la locale donnée
        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 retourne ltr, rtl ou auto pour la locale, ce qui permet à l'arabe et l'hébreu de s'afficher correctement.

    7. Changer la langue

      Changer de langue est une requête comme une autre. Le serveur stocke le choix dans le cookie que le middleware lit, puis retourne la page rendue dans la nouvelle locale.

      Affichez le sélecteur comme un select qui s'envoie lui-même et remplace tout le <body>, pour que les étiquettes statiques autour de vos fragments changent aussi :

      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) écrit chaque langue dans la langue actuellement affichée. Passez aucun deuxième argument pour écrire chacune dans sa propre langue à la place.

      Gérez la publication en validant la valeur, en définissant le cookie et en renvoyant le nouveau corps :

      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) => {
        const body = await c.req.parseBody();
        const requestedLocale = String(body["locale"]);
      
        if (!isDeclaredLocale(requestedLocale)) {
          return c.text("Locale inconnue", 400);
        }
      
        setCookie(c, "INTLAYER_LOCALE", requestedLocale, {
          sameSite: "Lax",
          path: "/",
        });
        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 restreint une chaîne arbitraire à l'une de vos locales configurées, donc une valeur inattendue ne atteint jamais vos renderers.
    8. Garder lang et dir synchronisés après un swap

      Facultatif

      Un échange peut remplacer le <body>, jamais le <html> qui l'entoure. Affichez lang et dir sur le corps échangé et copiez-les sur l'élément racine une fois, à partir de la tête :

      src/views.ts
      <script>
        document.addEventListener("htmx:afterSwap", () => {
          // Synchronise la langue et la direction du document avec le body après un échange HTMX
          document.documentElement.lang = document.body.lang;
          document.documentElement.dir = document.body.dir;
        });
      </script>
      

      Sans cela, un passage à l'arabe s'affiche de droite à gauche dans le corps tandis que le document annonce toujours la langue précédente aux technologies d'assistance et aux crawlers.

    9. Facultatif

      Si un cookie ne vous convient pas, attachez la locale à chaque requête htmx avec hx-headers sur un élément ancêtre. Les descendants l'hériteront :

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

      Le middleware lit x-intlayer-locale par défaut. Vous pouvez renommer les deux carriers dans votre configuration :

      intlayer.config.ts
      import { Locales, type IntlayerConfig } from "intlayer";
      
      const config: IntlayerConfig = {
        // ... Autres options de configuration
        routing: {
          storage: [
            { type: "header", name: "my-locale-header" },
            { type: "cookie", name: "my-locale-cookie" },
          ],
        },
      };
      
      export default config;
      

    Configurer TypeScript

    Incluez les types générés automatiquement afin qu'une clé non déclarée soit une erreur de compilation plutôt qu'une chaîne vide à l'exécution.

    tsconfig.json
    {
      // ... Vos configurations TypeScript existantes
      "include": [
        // ... Vos configurations TypeScript existantes
        ".intlayer/**/*.ts", // Inclure les types générés automatiquement
      ],
    }
    

    Configuration Git

    Il est recommandé d'ignorer les fichiers générés par Intlayer :

    .gitignore
    # Ignorer les fichiers générés par Intlayer
    .intlayer
    

    Extension VS Code

    Pour améliorer votre expérience de développement avec Intlayer, vous pouvez installer l'extension officielle Intlayer VS Code Extension.

    Installer depuis la VS Code Marketplace

    Cette extension fournit :

    • Autocomplétion pour les clés de traduction.
    • Détection d'erreurs en temps réel pour les traductions manquantes.
    • Aperçus intégrés du contenu traduit.
    • Actions rapides pour créer et mettre à jour facilement les traductions.

    Pour plus de détails sur la façon d'utiliser l'extension, consultez la documentation de l'extension Intlayer VS Code.


    Aller plus loin

    Pour aller plus loin, vous pouvez externaliser votre contenu en utilisant le CMS, afin que les traducteurs puissent modifier le contenu sans déploiement.

    Questions fréquemment posées

    Parce que la requête de fragment n'a pas transporté de locale. Les requêtes htmx sont indépendantes de la page qui les a émises, donc la locale doit voyager sur chacune d'entre elles, via le cookie INTLAYER_LOCALE ou un header x-intlayer-locale défini avec hx-headers. Vérifiez que le parser de cookie s'exécute avant le middleware Intlayer sur Express et Fastify, sinon le cookie n'est jamais lu et chaque requête revient à Accept-Language.

    Passez-la. Les intégrations exposent la locale résolue (res.locals.locale, req.intlayer.locale, c.get("locale"), intlayer!.locale), et la passer à getIntlayer fait de chaque renderer une fonction pure d'une locale. C'est plus facile à tester, et cela garde vos renderers de fragments portables si vous changez de serveur.

    Non. Tout ce qu'un visiteur voit est produit par le serveur, donc il n'y a rien à traduire dans le navigateur. C'est aussi pourquoi le coût du poids de la page pour l'i18n dans une app htmx est proche de zéro : aucun catalogue n'est jamais expédié vers le client.

    Servez vos pages sous un préfixe de locale (/fr/cart) et lisez la locale à partir du chemin dans votre gestionnaire de route, plutôt que depuis le cookie, pour le rendu complet de la page. Les fragments peuvent continuer à utiliser le cookie ou l'en-tête. Voir configuration pour les options de routage et réécriture d'URL personnalisée.

    getHTMLTextDir(locale) retourne ltr, rtl ou auto. Définissez-le sur le document pour le rendu initial, et réappliquez-le après un échange comme le montre l'étape 8. Utilisez les propriétés CSS logiques (margin-inline-start plutôt que margin-left) afin que votre mise en page suive.

    Oui, pour tout ce que vous interpolez dans une chaîne de template, exactement comme pour toute autre valeur dynamique. Le contenu provenant du CMS ou d'un traducteur n'est pas du markup que vous contrôlez. L'étape 5 montre un échappeur minimal.