Guides
Server rendering
Request scoping with AsyncLocalStorage, and the hydration agreement.
runWithLocale<T>(locale: string, fn: () => T): TwithLocale(handler, options: NegotiateOptions): (request: Request, ...rest) => Promise<Response>localeFromRequest(request: Request, options: NegotiateOptions): stringlocaleFromHeaders(headers: { cookie?: string; acceptLanguage?: string }, options: NegotiateOptions): stringnegotiate(accepted: readonly string[], options: NegotiateOptions): stringNo mutable global locale on the server: loclizr/server opens an AsyncLocalStorage scope per
request, so message calls resolve to that request’s locale and concurrent requests never see each
other. The negotiating functions take NegotiateOptions, { locales, sourceLocale, cookie? }; the
barrel exports all three.
Scope, stated first
Section titled “Scope, stated first”| Host | v0.1 |
|---|---|
Web Fetch handlers: Vite SSR, Hono, TanStack Start, Bun, Deno, Workers with nodejs_als |
withLocale |
Express, Fastify, anything with req.headers instead of a Request |
localeFromHeaders, then runWithLocale around the rest of the handler |
| React Router, Remix | runWithLocale in the root middleware, shown below; no test, no example, no promise |
| Nuxt, Nitro | not first-class: no test, no example, no promise; a Nitro plugin can open the scope |
| Next.js, App Router or Pages Router middleware | not first-class: no test, no example, no promise |
Limits explains the Next.js gap.
A Fetch handler
Section titled “A Fetch handler”import { withLocale } from 'loclizr/server'import * as m from './loclizr/messages.js'
const options = { locales: m.locales, sourceLocale: m.sourceLocale, cookie: m.cookie }
export const handler = withLocale( () => new Response(`<html lang="${m.getLocale()}"><h1>${m.cart_greeting({ name: 'Ada' })}</h1></html>`), options,)withLocale negotiates, runs the handler in the scope, sets Content-Language, and lists both
locale inputs, Accept-Language and Cookie, in Vary on every response.
The scope covers the handler and the async work it starts. A body produced as the client reads it
can render source-locale text under a Content-Language that names the negotiated locale; see
Bodies produced later.
Two steps are yours:
-
Render
Section titled “Render lang from the negotiated locale”langfrom the negotiated localeThe client reads the cookie, then
<html lang>, so a hardcodedlang="en"hydrates German markup as English for a visitor with no cookie. UsegetLocale(); outside productionuseLocalewarns once on a mismatch (React). -
Keep localized responses out of shared caches
Section titled “Keep localized responses out of shared caches”A shared cache in front of the handler must honour
Varyor key on the locale cookie. Most CDNs ignoreVaryoutsideAccept-Encoding, so unless yours keys on the cookie, sendCache-Control: private.
Three requests against the example catalogs:
cookie wins Content-Language: de-AT Vary: Accept-Language, Cookie body: <html lang="de-AT"><h1>Servus Ada, dein Einkaufswagerl ist fertig</h1></html>accept-language when no cookie Content-Language: de Vary: Accept-Language, Cookie body: <html lang="de"><h1>Hallo Ada, dein Warenkorb ist fertig</h1></html>nothing matches Content-Language: en Vary: Accept-Language, Cookie body: <html lang="en"><h1>Hi Ada, your cart is ready</h1></html>| Request carried | Result |
|---|---|
cookie: locale=de-AT and accept-language: en |
the cookie won |
accept-language: de-CH,de;q=0.9,en;q=0.8, no cookie |
de-CH truncated to the declared de |
fr |
the source locale |
Cookie first, then Accept-Language
Section titled “Cookie first, then Accept-Language”import { localeFromHeaders, runWithLocale } from 'loclizr/server'import * as m from '../src/loclizr/messages.js'
const options = { locales: m.locales, sourceLocale: m.sourceLocale, cookie: m.cookie }
app.get('/cart', (req, res) => { const locale = localeFromHeaders( { cookie: req.headers.cookie, acceptLanguage: req.headers['accept-language'] }, options, ) runWithLocale(locale, () => { res.set('Content-Language', locale) res.send(render()) })})Express and Fastify hold no Request, so they call localeFromHeaders; localeFromRequest wraps
it for a Request. loclizr/server installs the scope when it loads; no per-request state lives
outside it. Order:
- The configured cookie.
Accept-Language, throughnegotiate: RFC 4647 lookup by subtag truncation, ranked by quality, no dependency.- The source locale.
React Router and Remix
Section titled “React Router and Remix”Loaders and actions run before the render, and client navigations fetch them as .data requests
that never reach entry.server. Open the scope in the root middleware, so it covers the whole
request: document and data requests, loaders, actions and the render. React Router 7 runs route
middleware only with future: { v8_middleware: true } in react-router.config.ts; without it the
export is ignored and nothing warns.
import { localeFromRequest, runWithLocale } from 'loclizr/server'import type { Route } from './+types/root'import * as m from '../src/loclizr/messages'
const options = { locales: m.locales, sourceLocale: m.sourceLocale, cookie: m.cookie }
export const middleware: Route.MiddlewareFunction[] = [ ({ request }, next) => runWithLocale(localeFromRequest(request, options), () => next()),]A fragment of app/root.tsx, not covered by a test. Wrapping only the render, or wrapping
entry.server’s handleRequest with withLocale, leaves loaders, actions and client .data
requests in the source locale.
Nuxt and Nitro
Section titled “Nuxt and Nitro”Not first-class: no test, no example, no promise. Nuxt middleware runs before the renderer and cannot wrap it, so the Express pattern has nowhere to go. A Nitro plugin can wrap the app handler instead:
import { localeFromHeaders, runWithLocale } from 'loclizr/server'import * as m from '../../src/loclizr/messages'
const options = { locales: m.locales, sourceLocale: m.sourceLocale, cookie: m.cookie }
export default defineNitroPlugin((nitroApp) => { const inner = nitroApp.h3App.handler nitroApp.h3App.handler = (event) => runWithLocale( localeFromHeaders( { cookie: getRequestHeader(event, 'cookie'), acceptLanguage: getRequestHeader(event, 'accept-language') }, options, ), () => inner(event), )})An unverified fragment of server/plugins/loclizr.ts, where Nuxt auto-imports defineNitroPlugin
and getRequestHeader. Render <html lang> from getLocale() through useHead. Without the plugin,
read the locale with localeFromHeaders and pass { locale } to every message call.
Outside a request
Section titled “Outside a request”loclizr: a message or getLocale() ran outside a request scope, so it used the source locale. Wrap the whole request in runWithLocale(): loaders, actions and the render.| Call | Inside a scope | Outside any scope |
|---|---|---|
| A message | the request’s locale | source-locale text |
A message with { locale } |
that locale | that locale, with no warning |
getLocale() |
the request’s locale | the source locale |
Outside production, the first message or getLocale() call outside a scope prints the warning above,
once per process, wherever loclizr/server is loaded.
Inside a scope setLocale throws; pass { locale } per call.
It warns rather than throws because prerender, jobs, scripts and error boundaries run with no
request; wrap them in runWithLocale for another language. On a request the scope covers the
whole request, loaders and actions included, not the render alone.
Bodies produced later
Section titled “Bodies produced later”The scope covers the handler and the async work it starts. A body produced as the client reads it
can run outside the scope: a ReadableStream pull(), an async generator body, ReadableStream.from. A plain
message call there returns source-locale text, which the warning above reports, while
Content-Language names the negotiated locale.
import { withLocale } from 'loclizr/server'import * as m from './loclizr/messages.js'
const options = { locales: m.locales, sourceLocale: m.sourceLocale, cookie: m.cookie }const encoder = new TextEncoder()
export const handler = withLocale(() => { const locale = m.getLocale() // inside the scope const body = new ReadableStream<Uint8Array>({ pull(controller) { // may run outside the scope controller.enqueue(encoder.encode(m.cart_greeting({ name: 'Ada' }, { locale }))) controller.close() }, }) return new Response(body)}, options)Read getLocale() inside the handler and pass { locale } to each message call, or wrap the
producer’s work in runWithLocale(locale, ...).
Dates on the server
Section titled “Dates on the server”import { defineConfig } from 'loclizr'
export default defineConfig({ formats: { timeZone: 'UTC' }, severity: { 'date-without-timezone': 'error' },})A zoneless date formats in the server’s zone, then the viewer’s on hydration: one instant, two
days. LZ3011 date-without-timezone flags every date and time
style with no zone; it ships off because an SPA wants the viewer’s zone.
Server-rendered dates: turn it on and set formats.timeZone, merged into every date and time style.