L5E docscontent/27-i18n.md

i18n (locale)

L5E ships no locale-detection logic of its own — no URL-prefix stripping, no cookie strategy, no Accept-Language negotiation. Two reasons:

  • Libraries already do this well. Paraglide JS resolves locale from URL/cookie/header, redirects, and exposes an ambient getLocale() that works identically on the server and in the browser. Rebuilding a worse copy inside the framework buys nothing.
  • Plenty of apps don't need a library at all. A single locals.locale = ... line in middleware is a complete i18n setup for a two-locale site. Forcing a library on those apps would be the wrong default.

So L5E gives you one thing: a bridge so any fetch-shaped locale middleware plugs straight into the framework's own middleware chain.

import { fromFetchMiddleware } from '@withl5e/l5e/i18n';

Setup (with Paraglide JS)

1. Install and compile your messages

npm install @inlang/paraglide-js
npx paraglide-js init

This scaffolds project.inlang/ and a messages/{locale}.json per locale. Compile once (and again on every dev/build — wire it into those scripts):

npx paraglide-js compile --project ./project.inlang --outdir ./src/paraglide --emit-ts-declarations

2. Configure the Vite plugin

// vite.config.js
import { paraglideVitePlugin } from '@inlang/paraglide-js';

export default defineConfig({
  plugins: [
    paraglideVitePlugin({
      project: './project.inlang',
      outdir: './src/paraglide',
      strategy: ['url', 'cookie', 'baseLocale'],
      cookieName: 'app_locale',
    }),
    // …coreVite(), etc.
  ],
});

strategy order matters: put 'url' first for normal page navigation. Paraglide's own client-side getLocale() re-syncs a cookie to whatever page is currently open — if 'cookie' came first, an explicit navigation to a different locale's URL would immediately fight that stale cookie and bounce back to the previous locale (shouldRedirect sees a mismatch and redirects). 'url' winning avoids that loop.

3. Wire the middleware

// src/middleware.ts
import { sequence } from '@withl5e/l5e/middleware';
import { fromFetchMiddleware } from '@withl5e/l5e/i18n';
import { paraglideMiddleware } from '~/paraglide/server.js';

export const onRequest = sequence(fromFetchMiddleware(paraglideMiddleware));

That's the whole integration. fromFetchMiddleware hands context.request to paraglideMiddleware, and once Paraglide resolves a locale, calls next() — inside Paraglide's own resolve callback — with the (possibly de-localized) request. It also writes the resolved locale onto context.locals.locale, since that's where the rest of L5E conventionally reads request-scoped values from (see [[10-userequest-and-locals]]).

Usage — read the locale from anywhere, ambiently

Because fromFetchMiddleware calls next() synchronously inside Paraglide's own AsyncLocalStorage.run(), that scope naturally extends over everything next() triggers — the route handler, every loader, every server-rendered component. Paraglide's own getLocale() just works there, with no L5E-specific accessor needed:

// src/global-loader.ts — sets `lang` for every view, once
import { getLocale } from '~/paraglide/runtime.js';
import type { LoaderFunction } from '@withl5e/l5e/entry-server';

export const loader: LoaderFunction = async () => ({ lang: getLocale() });
// any component rendered during this request — no prop-drilling
import { getLocale } from '~/paraglide/runtime.js';
import { m } from '~/paraglide/messages.js';

export function LanguageBadge() {
  return <span data-locale={getLocale()}>{m.greeting()}</span>;
}

The same getLocale() import works client-side too (it falls back to its own URL/cookie/localStorage strategies in the browser) — one function, one mental model, everywhere in the app. That's also why L5E doesn't add its own getLocale() on top: it would just be a second API to remember for no gain.

Non-navigable endpoints (server actions, tooltip fragments fetched without a locale-prefixed URL of their own) never carry a /vi/...-style prefix, so 'url' can't resolve anything for them. Give them a narrower per-route strategy with Paraglide's routeStrategies so 'cookie' decides instead:

paraglideVitePlugin({
  // …
  routeStrategies: [
    { match: ':protocol://:domain(.*)::port?/_l5e/:path(.*)?', strategy: ['cookie', 'baseLocale'] },
  ],
}),

React islands don't need a locale prop either. They run in the browser with no AsyncLocalStorage, but Paraglide's client-side getLocale() doesn't need it there — it resolves from window.location (the 'url' strategy) instead, and the browser only ever has one "current page" to resolve for, unlike the server juggling many concurrent requests. An island can just call getLocale() itself and get the exact same answer the server rendered with, both on first paint (hydrating an ssr island) and on a client-only mount.

Tooltip fragments can be locale-prefixed too, so a CDN caches each locale as a distinct URL instead of one URL that varies by cookie (most CDNs can't cache that correctly). Call configureTooltip('auto-locale') once — e.g. in client.global.ts, so it's set up regardless of which view's own client script mounts a tooltip — and every tooltip fetch in the app infers its prefix from <html lang> + the current URL:

// src/client.global.ts
import { configureTooltip } from '@withl5e/l5e/tooltip';

configureTooltip('auto-locale');

No per-element wiring, and the default (no call at all) is unchanged — /tooltip/:type/:id with no prefix — since l5e isn't an i18n framework and most tooltip users don't localize URLs at all. A fully custom URL scheme is just as easy: configureTooltip(({ type, id }) => \/api/v2/tooltips/${type}-${id}`)`.

Demo: every L5E feature working with i18n

examples/i18n in the L5E repo is a complete, running app exercising this end to end — not just the middleware bridge in isolation:

  • Loader reading requestInfo directly, without needing to also return lang (global-loader.ts owns that for every view)
  • generateMetadata and generateSchema producing a locale-aware <title>, JSON-LD inLanguage, and <link rel="alternate" hreflang> tags for every configured locale — computed once in global-loader.ts from requestInfo.pathname, so every view gets correct alternates automatically (see [[23-metadata]])
  • useClientJs, a server action (defineAction + createSwap), and a tooltip fragment route — all reading the request's locale
  • A React island mounted twice — SSR (hydrated, server-rendered count survives) and CSR (client-only, starts fresh) — both calling getLocale() themselves, no prop

tests/e2e/specs/i18n.spec.ts drives all of it through a real Chromium browser against the production build (locale switching, the redirect-loop regression guard, action/ tooltip cookie fallback, concurrent-request isolation, both islands).

Not using Paraglide — or any library — at all

getLocale() and fromFetchMiddleware are conveniences, not requirements. locals is the only thing that actually matters, and middleware can set it directly:

// src/middleware.ts — no library, no adapter, just locals
import { defineMiddleware, sequence } from '@withl5e/l5e/middleware';

const detectLocale = defineMiddleware((ctx, next) => {
  const accept = ctx.request.headers.get('accept-language') ?? '';
  ctx.locals.locale = accept.startsWith('vi') ? 'vi' : 'en';
  return next();
});

export const onRequest = sequence(detectLocale);
import { useRequest } from '@withl5e/l5e/jsx-runtime';

function Greeting() {
  const { locals } = useRequest();
  const locale = (locals.locale as string | undefined) ?? 'en';
  return <p>{locale === 'vi' ? 'Xin chào!' : 'Hello!'}</p>;
}

This is a complete, valid i18n setup for an app with a couple of locales and no need for translation-file tooling. Reach for a library only once message management, pluralization, or many locales make it worth the dependency.

Not using a fetch-shaped library?

fromFetchMiddleware expects (request, resolve, options?) => Response — the shape Paraglide's paraglideMiddleware happens to match exactly. Any other library with the same shape works too; a library that doesn't (or a hand-rolled function) can just skip the adapter and set locals.locale directly, as shown above.