Autor:
    Creación:2026-08-29Última actualización:2026-08-29

    Traduce tu aplicación htmx usando Intlayer | Internacionalización (i18n)

    htmx no renderiza contenido propio. Cada etiqueta que ve un visitante es HTML que tu servidor produjo, y cada intercambio es una solicitud HTTP separada. Internacionalizar una aplicación htmx es por lo tanto una preocupación del servidor: la locale tiene que resolverse en cada solicitud, y cada fragmento tiene que renderizarse en esa locale.

    Intlayer cubre esto a través de sus integraciones de backend, que detectan la locale por solicitud y exponen tu contenido declarado al controlador que construye el HTML.

    Tabla de Contenidos

    Las tres reglas de i18n en una aplicación htmx

    Una sola página puede desencadenar docenas de intercambios. Cada uno es una solicitud nueva sin memoria de la página que la emitió. Si la configuración regional vive en una variable establecida durante la representación inicial, cada fragmento después de ella vuelve al idioma predeterminado.

    El middleware de Intlayer resuelve la configuración regional de la solicitud misma, por lo que un fragmento servido en el minuto diez responde en el mismo idioma que la página servida en el minuto cero.

    Dos portadores funcionan con htmx. Una cookie (INTLAYER_LOCALE) es enviada automáticamente por el navegador en cada solicitud, incluyendo las de htmx. Un encabezado (x-intlayer-locale) puede adjuntarse a las solicitudes de htmx con el atributo hx-headers. Ambos se leen por defecto.

    Un valor traducido interpolado en un fragmento es markup. Escápalo, exactamente como lo harías con cualquier otro valor dinámico, para que una traducción que contenga < no pueda romper el documento en el que se intercambia.


    Guía Paso a Paso

    ide.intlayer.org

    Consulta la Plantilla de Aplicación en GitHub.

    1. Instalar Dependencias

      Instala intlayer más la integración para tu servidor.

      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 y Fastify leen la cookie de locale a través de sus propios analizadores de cookies, por lo que deben instalarse junto con ellos. Hono y Elysia analizan cookies de forma nativa.

      htmx en sí es una única etiqueta de script, agregada en el paso 4.

    2. Configuración de tu proyecto

      Crea un intlayer.config.ts en la raíz de tu proyecto:

      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;
      
      Para obtener la lista completa de opciones, consulta la documentación de configuración.
    3. Declarar tu contenido

      Declara cada etiqueta que el servidor renderizará, incluyendo las que solo aparecen dentro de un fragmento:

      src/app.content.ts
      import { insert, t, type Dictionary } from "intlayer";
      
      const appContent = {
        key: "app",
        content: {
          pageTitle: "Intlayer + htmx",
      
          localeLabel: t({
            es: "Idioma",
            en: "Language",
            fr: "Langue",
            ar: "اللغة",
          }),
      
          cartSummary: insert(
            t({
              es: "Artículos en tu carrito: {{count}}",
              en: "Items in your cart: {{count}}",
              fr: "Articles dans votre panier : {{count}}",
              ar: "المنتجات في سلتك: {{count}}",
            })
          ),
      
          addItem: t({
            es: "Añadir un artículo",
            en: "Add an item",
            fr: "Ajouter un article",
            ar: "أضف منتجًا",
          }),
        },
      } satisfies Dictionary;
      
      export default appContent;
      
      Las declaraciones de contenido pueden vivir en cualquier lugar bajo contentDir (por defecto ./src) y coincidir .content.{json,ts,tsx,js,jsx,mjs,cjs,md,mdx,yaml,yml}. Consulta la documentación de declaración de contenido.
    4. Registrar el middleware de Intlayer

      El middleware resuelve la configuración regional de cada solicitud y la expone a tus manejadores.

      src/index.ts
      import cookieParser from "cookie-parser";
      import express from "express";
      import { intlayer } from "express-intlayer";
      
      const app = express();
      
      // El analizador de cookies debe ejecutarse primero: `express-intlayer` lee la configuración regional
      // cookie a través de `req.cookies`.
      app.use(cookieParser());
      app.use(express.urlencoded({ extended: false }));
      app.use(intlayer());
      

      La configuración regional resuelta está en res.locals.locale.

      src/index.ts
      
      
      </budget:token_budget>
      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 configuración regional resuelta está en req.intlayer.locale.

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

      La configuración regional resuelta es c.get("locale").

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

      La configuración regional resuelta es intlayer!.locale en el contexto de la ruta.

      Por defecto, la configuración regional se toma de la cookie INTLAYER_LOCALE, luego del encabezado x-intlayer-locale, luego de la negociación Accept-Language.

    5. Renderizar fragmentos con la configuración regional de la solicitud

      Escribe tus renderizadores de fragmentos como funciones puras de una configuración regional, y pasa la configuración regional que el middleware resolvió. Pasarla explícitamente mantiene un fragmento vinculado a la solicitud que lo pidió, sin importar en qué servidor estés.

      src/views.ts
      import { currency, getIntlayer, type Locale } from "intlayer";
      
      const HTML_ENTITIES: Record<string, string> = {
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
      };
      
      /** Escapa un valor traducido para que no pueda salir del marcado. */
      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>`;
      };
      

      Sírvelo desde una ruta:

      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" },
        });
      });
      

      El mismo fragmento ahora responde en francés para un visitante cuya cookie dice fr, y en árabe para uno cuya cookie dice ar, sin cambios en el marcado de llamada.

    6. Servir la primera página

      Renderiza el <body> por sí solo, para que el cambiador de idioma en el paso 7 pueda intercambiarlo completamente, luego envuélvelo en el documento que carga htmx:

      src/views.ts
      import { getHTMLTextDir, getIntlayer, type Locale } from "intlayer";
      
      export const renderBody = (locale: Locale, itemCount: number): string => {
        // Obtener el contenido internacionalizado para la locale especificada
        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 devuelve ltr, rtl o auto para la locale, lo que permite que el árabe y el hebreo se muestren correctamente.

    7. Cambiar el idioma

      Cambiar el idioma es una solicitud como cualquier otra. El servidor almacena la selección en la cookie que lee el middleware, y luego devuelve la página renderizada nuevamente en la nueva locale.

      Renderiza el selector como un select que se envía a sí mismo e intercambia todo el <body>, para que las etiquetas estáticas alrededor de tus fragmentos también cambien:

      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) escribe cada idioma en el idioma actualmente mostrado. No pases un segundo argumento para escribir cada uno en su propio idioma en su lugar.

      Maneja la publicación validando el valor, estableciendo la cookie y devolviendo el nuevo cuerpo:

      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("Idioma desconocido");
        }
      
        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) => {
        // Analizar el cuerpo de la solicitud
        const body = await c.req.parseBody();
        // Obtener la locale solicitada del cuerpo
        const requestedLocale = String(body["locale"]);
      
        // Verificar si la locale está declarada
        if (!isDeclaredLocale(requestedLocale)) {
          return c.text("Locale desconocida", 400);
        }
      
        // Establecer la cookie de locale
        setCookie(c, "INTLAYER_LOCALE", requestedLocale, {
          sameSite: "Lax",
          path: "/",
        });
        // Retornar la respuesta HTML renderizada
        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 estrecha una cadena arbitraria a uno de tus locales configurados, por lo que un valor inesperado nunca llega a tus renderizadores.
    8. Mantener lang y dir sincronizados después de un swap

      Opcional

      Un swap puede reemplazar el <body>, nunca el <html> que lo rodea. Renderiza lang y dir en el body intercambiado y cópialos de vuelta al elemento raíz una vez, desde el head:

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

      Sin esto, un cambio al árabe renderiza de derecha a izquierda dentro del body mientras el documento aún anuncia el idioma anterior a la tecnología de asistencia y a los crawlers.

    9. Opcional

      Si una cookie no te conviene, adjunta la locale a cada solicitud htmx con hx-headers en un elemento ancestro. Los descendientes la heredan:

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

      El middleware lee x-intlayer-locale por defecto. Puedes renombrar ambos portadores en tu configuración:

      intlayer.config.ts
      import { Locales, type IntlayerConfig } from "intlayer";
      
      const config: IntlayerConfig = {
        // ... Otras opciones de configuración
        routing: {
          storage: [
            { type: "header", name: "my-locale-header" },
            { type: "cookie", name: "my-locale-cookie" },
          ],
        },
      };
      
      export default config;
      

    Configurar TypeScript

    Incluye los tipos autogenerados para que una clave no declarada sea un error de compilación en lugar de una cadena vacía en tiempo de ejecución.

    tsconfig.json
    {
      // ... Tus configuraciones de TypeScript existentes
      "include": [
        // ... Tus configuraciones de TypeScript existentes
        ".intlayer/**/*.ts", // Incluir los tipos autogenerados
      ],
    }
    

    Configuración de Git

    Se recomienda ignorar los archivos generados por Intlayer:

    .gitignore
    # Ignorar los archivos generados por Intlayer
    .intlayer
    

    Extensión de VS Code

    Para mejorar tu experiencia de desarrollo con Intlayer, puedes instalar la Extensión oficial de Intlayer para VS Code.

    Instalar desde VS Code Marketplace

    Esta extensión proporciona:

    • Autocompleción para claves de traducción.
    • Detección de errores en tiempo real para traducciones faltantes.
    • Vistas previas en línea del contenido traducido.
    • Acciones rápidas para crear y actualizar traducciones fácilmente.

    Para más detalles sobre cómo usar la extensión, consulta la documentación de la extensión Intlayer VS Code.


    Ir más allá

    Para ir más allá, puedes externalizar tu contenido usando el CMS, para que los traductores cambien el contenido sin necesidad de una implementación.

    Preguntas Frecuentes

    Porque la solicitud del fragmento no llevaba ninguna configuración regional. Las solicitudes de htmx son independientes de la página que las emitió, por lo que la configuración regional tiene que viajar en cada una, a través de la cookie INTLAYER_LOCALE o un encabezado x-intlayer-locale establecido con hx-headers. Comprueba que el analizador de cookies se ejecuta antes del middleware de Intlayer en Express y Fastify, de lo contrario la cookie nunca se lee y cada solicitud vuelve a Accept-Language.

    Pásalo. Las integraciones exponen la locale resuelta (res.locals.locale, req.intlayer.locale, c.get("locale"), intlayer!.locale), y pasarla a getIntlayer hace que cada renderer sea una función pura de una locale. Eso es más fácil de probar, y mantiene tus fragment renderers portátiles si cambias de servidor.

    No. Todo lo que ve un visitante es producido por el servidor, así que no hay nada que traducir en el navegador. Por eso también el costo de peso de la página del i18n en una app htmx es casi cero: ningún catálogo se envía nunca al cliente.

    Sirva sus páginas bajo un prefijo de locale (/fr/cart) y lea el locale de la ruta en su controlador de rutas, en lugar de desde la cookie, para el renderizado completo de la página. Los fragmentos pueden seguir utilizando la cookie o el encabezado. Véase configuración para las opciones de enrutamiento y reescrituras de URL personalizadas.

    getHTMLTextDir(locale) devuelve ltr, rtl o auto. Establézcalo en el documento para el renderizado inicial y vuelva a aplicarlo después de un intercambio como se muestra en el paso 8. Utilice propiedades lógicas de CSS (margin-inline-start en lugar de margin-left) para que su diseño se ajuste.

    Sí, para cualquier cosa que interpoles en una cadena de plantilla, exactamente como para cualquier otro valor dinámico. El contenido proveniente del CMS o de un traductor no es markup que controles. El paso 5 muestra un escapador mínimo.