Ask your question and get a summary of the document by referencing this page and the AI provider of your choice
Version History
- "Initial history"v9.4.129/08/2026
The content of this page was translated using an AI.
See the last version of the original content in EnglishIf you have an idea for improving this documentation, please feel free to contribute by submitting a pull request on GitHub.
GitHub link to the documentationCopy doc Markdown to clipboard
Translate your htmx application using Intlayer | Internationalisation (i18n)
htmx renders no content of its own. Every label a visitor reads is HTML your server produced, and every swap is a separate HTTP request. Internationalising an htmx app is therefore a server concern: the locale has to be resolved on each request, and each fragment has to be rendered in that locale.
Intlayer covers this through its backend integrations, which detect the locale per request and expose your declared content to the handler that builds the HTML.
Table of Contents
The three rules of i18n in an htmx app
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.
A translated value interpolated into a fragment is markup. Escape it, exactly as you would any other dynamic value, so a translation containing < cannot break the document it is swapped into.
Step-by-Step Guide
See Application Template on GitHub.
Install Dependencies
Install
intlayerplus the integration for your server.bashCopy codeCopy the code to the clipboard
bashCopy codeCopy the code to the clipboard
bashCopy codeCopy the code to the clipboard
bashCopy codeCopy the code to the clipboard
bashCopy codeCopy the code to the clipboard
Express and Fastify read the locale cookie through their own cookie parsers, so those have to be installed alongside. Hono and Elysia parse cookies natively.
htmx itself is a single script tag, added in step 4.
Configuration of your project
Create an
intlayer.config.tsat your project root:intlayer.config.tsCopy codeCopy the code to the clipboard
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;For the full list of options, see the configuration documentation.
Declare Your Content
Declare every label the server will render, including the ones that only ever appear inside a fragment:
src/app.content.tsCopy codeCopy the code to the clipboard
import { insert, t, type Dictionary } from "intlayer"; const appContent = { key: "app", content: { pageTitle: "Intlayer + htmx", localeLabel: t({ en: "Language", fr: "Langue", es: "Idioma", ar: "اللغة", }), cartSummary: insert( t({ "en-GB": "Items in your cart: {{count}}", en: "Items in your cart: {{count}}", fr: "Articles dans votre panier : {{count}}", es: "Artículos en tu carrito: {{count}}", ar: "المنتجات في سلتك: {{count}}", }) ), addItem: t({ "en-GB": "Add an item", en: "Add an item", fr: "Ajouter un article", es: "Añadir un artículo", ar: "أضف منتجًا", }), }, } satisfies Dictionary; export default appContent;Content declarations can live anywhere under
contentDir(by default./src) and match.content.{json,ts,tsx,js,jsx,mjs,cjs,md,mdx,yaml,yml}. See the content declaration documentation.Register the Intlayer middleware
The middleware resolves the locale of each request and exposes it to your handlers.
src/index.tsCopy codeCopy the code to the clipboard
import cookieParser from "cookie-parser"; import express from "express"; import { intlayer } from "express-intlayer"; const app = express(); // The cookie parser has to run first: `express-intlayer` reads the locale // cookie through `req.cookies`. app.use(cookieParser()); app.use(express.urlencoded({ extended: false })); app.use(intlayer());The resolved locale is on
res.locals.locale.src/index.tsCopy codeCopy the code to the clipboard
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);The resolved locale is on
req.intlayer.locale.src/index.tsCopy codeCopy the code to the clipboard
import { Hono } from "hono"; import { intlayer } from "hono-intlayer"; const app = new Hono(); app.use("*", intlayer());The resolved locale is
c.get("locale").src/index.tsCopy codeCopy the code to the clipboard
import { Elysia } from "elysia"; import { intlayer } from "elysia-intlayer"; const app = new Elysia().use(intlayer());The resolved locale is
intlayer!.localeon the route context.By default the locale is taken from the
INTLAYER_LOCALEcookie, then thex-intlayer-localeheader, thenAccept-Languagenegotiation.Render fragments with the request locale
Write your fragment renderers as pure functions of a locale, and pass the locale the middleware resolved. Passing it explicitly keeps a fragment tied to the request that asked for it, whichever server you are on.
src/views.tsCopy codeCopy the code to the clipboard
import { currency, getIntlayer, type Locale } from "intlayer"; const HTML_ENTITIES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; /** Escapes a translated value so it cannot break out of the 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>`; };Serve it from a route:
src/index.tsCopy codeCopy the code to the clipboard
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.tsCopy codeCopy the code to the clipboard
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.tsCopy codeCopy the code to the clipboard
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.tsCopy codeCopy the code to the clipboard
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" }, }); });The same fragment now answers in French for a visitor whose cookie says
fr, and in Arabic for one whose cookie saysar, with no change to the calling markup.Serve the first page
Render the
<body>on its own, so the locale switcher in step 7 can swap it whole, then wrap it in the document that loads htmx:src/views.tsCopy codeCopy the code to the clipboard
import { getHTMLTextDir, getIntlayer, type Locale } from "intlayer"; export const renderBody = (locale: Locale, itemCount: number): string => { // Get the content for the specified locale 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>`;getHTMLTextDirreturnsltr,rtlorautofor the locale, which is what makes Arabic and Hebrew display correctly.Switch the language
Switching language is a request like any other. The server stores the choice in the cookie the middleware reads, then returns the page re-rendered in the new locale.
Render the switcher as a
selectthat posts itself and swaps the whole<body>, so the static labels around your fragments change too:src/views.tsCopy codeCopy the code to the clipboard
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)writes each language in the language currently displayed. Pass no second argument to write each one in its own language instead.Handle the post by validating the value, setting the cookie, and returning the new body:
src/index.tsCopy codeCopy the code to the clipboard
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.tsCopy codeCopy the code to the clipboard
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.tsCopy codeCopy the code to the clipboard
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("Unknown locale", 400); } setCookie(c, "INTLAYER_LOCALE", requestedLocale, { sameSite: "Lax", path: "/", }); return c.html(renderBody(requestedLocale, 0)); });src/index.tsCopy codeCopy the code to the clipboard
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" }, }); });isDeclaredLocalenarrows an arbitrary string to one of your configured locales, so an unexpected value never reaches your renderers.Keep lang and dir in sync after a swap
OptionalA swap can replace the
<body>, never the<html>around it. Renderlanganddiron the swapped body and copy them back onto the root element once, from the head:src/views.tsCopy codeCopy the code to the clipboard
Without this, a switch to Arabic renders right to left inside the body whilst the document still advertises the previous language to assistive technology and to crawlers.
Send the locale as a header instead of a cookie
OptionalIf a cookie does not suit you, attach the locale to every htmx request with
hx-headerson an ancestor element. Descendants inherit it:htmlCopy codeCopy the code to the clipboard
The middleware reads
x-intlayer-localeby default. You can rename both carriers in your configuration:intlayer.config.tsCopy codeCopy the code to the clipboard
import { Locales, type IntlayerConfig } from "intlayer"; const config: IntlayerConfig = { // ... Other configuration options routing: { storage: [ { type: "header", name: "my-locale-header" }, { type: "cookie", name: "my-locale-cookie" }, ], }, }; export default config;
Configure TypeScript
Include the autogenerated types so an undeclared key is a compile error rather than an empty string at runtime.
Copy the code to the clipboard
Git Configuration
It is recommended to ignore the files generated by Intlayer:
Copy the code to the clipboard
VS Code Extension
To improve your development experience with Intlayer, you can install the official Intlayer VS Code Extension.
Install from the VS Code Marketplace
This extension provides:
- Autocompletion for translation keys.
- Real-time error detection for missing translations.
- Inline previews of translated content.
- Quick actions to easily create and update translations.
For more details on how to use the extension, refer to the Intlayer VS Code Extension documentation.
Go Further
To go further, you can externalise your content using the CMS, so translators change copy without a deployment.
Frequently Asked Questions
Because the fragment request carried no locale. htmx requests are independent of the page that issued them, so the locale has to travel on each one, through the INTLAYER_LOCALE cookie or an x-intlayer-locale header set with hx-headers. Check that the cookie parser runs before the Intlayer middleware on Express and Fastify, otherwise the cookie is never read and every request falls back to Accept-Language.
Pass it. The integrations expose the resolved locale (res.locals.locale, req.intlayer.locale, c.get("locale"), intlayer!.locale), and handing it to getIntlayer makes each renderer a pure function of a locale. That is easier to test, and it keeps your fragment renderers portable if you change server.
No. Everything a visitor sees is produced by the server, so there is nothing to translate in the browser. That is also why the page weight cost of i18n in an htmx app is close to zero: no catalogue is ever shipped to the client.
Serve your pages under a locale prefix (/fr/cart) and read the locale from the path in your route handler, rather than from the cookie, for the full page render. Fragments can keep using the cookie or the header. See configuration for the routing options and custom URL rewrites.
getHTMLTextDir(locale) returns ltr, rtl or auto. Set it on the document for the initial render, and re-apply it after a swap as step 8 shows. Use CSS logical properties (margin-inline-start rather than margin-left) so your layout follows.
Yes, for anything you interpolate into a template string, exactly as for any other dynamic value. Content coming from the CMS or from a translator is not markup you control. Step 5 shows a minimal escaper.
