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 initThis 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-declarations2. 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
requestInfodirectly, without needing to also returnlang(global-loader.tsowns that for every view) generateMetadataandgenerateSchemaproducing a locale-aware<title>, JSON-LDinLanguage, and<link rel="alternate" hreflang>tags for every configured locale — computed once inglobal-loader.tsfromrequestInfo.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.