Skip to content
loclizr
runWithLocale<T>(locale: string, fn: () => T): T
withLocale(handler, options: NegotiateOptions): (request: Request, ...rest) => Promise<Response>
localeFromRequest(request: Request, options: NegotiateOptions): string
localeFromHeaders(headers: { cookie?: string; acceptLanguage?: string }, options: NegotiateOptions): string
negotiate(accepted: readonly string[], options: NegotiateOptions): string

No 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.

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.

GET /cart with a cookie and Accept-Language reaches withLocale, which negotiates and opens a runWithLocale scope; the handler renders through the generated message functions and the response carries Content-Language. In the browser the store reads the cookie first, so hydration lands on the same locale.

src/server.ts
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:

  1. The client reads the cookie, then <html lang>, so a hardcoded lang="en" hydrates German markup as English for a visitor with no cookie. Use getLocale(); outside production useLocale warns once on a mismatch (React).

  2. 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 Vary or key on the locale cookie. Most CDNs ignore Vary outside Accept-Encoding, so unless yours keys on the cookie, send Cache-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
server/app.ts
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:

  1. The configured cookie.
  2. Accept-Language, through negotiate: RFC 4647 lookup by subtag truncation, ranked by quality, no dependency.
  3. The source locale.

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.

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.

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.

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.

src/stream.ts
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, ...).

loclizr.config.ts
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.