Localization
@warlock.js/web binds every render to one locale — the one the request
resolved — and exposes it through useLocale(). localeDirection(),
useTextDirection(), and changeLocaleCode() are new in 5.12.
Route dictionaries — locales.json
Section titled “Route dictionaries — locales.json”Put a JSON dictionary anywhere below src/web. Its values are complete
per-locale leaves; Web discovers the physical ancestor chain for the page and
selects one immutable locale snapshot for SSR, hydration, client navigation,
and error rendering. It never merges route dictionaries into
@mongez/localization’s global registry.
{ "save": { "en": "Save", "ar": "حفظ" }, "profile": { "title": { "en": "Profile", "ar": "الملف الشخصي" } }}Without $group, a file’s namespace comes from its static directories below
src/web: src/web/account/locales.json owns account.save and
account.profile.title. The root src/web/locales.json has no implicit
namespace. Route groups such as (marketing) and dynamic directories such as
[id] do not contribute namespace segments. Set a nonempty $group at the
JSON root to replace that derived prefix:
{ "$group": "settings.profile", "save": { "en": "Save", "ar": "حفظ" }}This file owns settings.profile.save, not account.save. $group is not a
prefix to append, cannot be empty, and is allowed only at the JSON root.
Every flattened key has one owner across every route dictionary: duplicate
keys, including an ancestor/child conflict, stop discovery with both source
files named. Each leaf must provide the configured locale codes, so there is
no route-JSON fallback for a missing translation.
Read the current locale
Section titled “Read the current locale”import { useLocale } from "@warlock.js/web";
export function LocaleBadge() { const locale = useLocale();
return <span>{locale}</span>;}useLocale() throws when called outside Warlock’s LocaleProvider — render
the component through the normal page pipeline.
Translate with useTrans()
Section titled “Translate with useTrans()”import { useTrans } from "@warlock.js/web";
export function Greeting() { const trans = useTrans();
return <p>{trans("welcome")}</p>;}useTrans() translates against the current request/page locale directly,
without consulting any process-global locale.
On the server, Web supplies the same scoped snapshot to t() from
@warlock.js/core (via the per-request binding), and explicit request.transFrom(locale, key, placeholders?).
That override applies only while the Web request is rendering; code outside a
route snapshot keeps the configured localization behavior. A JSON key absent
from the selected snapshot returns its key rather than falling back to another
route dictionary. Non-string translatable values continue through the regular
localization behavior.
The same selected snapshot reaches every render
Section titled “The same selected snapshot reaches every render”The hydration payload carries only the selected locale snapshot and installs
it before the page hydrates. A navigation or locale switch replaces that
snapshot with the destination page’s selection. This preserves matching
server/client output without exposing dictionaries from unrelated routes or
mutating the global registry. useTrans() is the client component API; it
reads this scoped snapshot directly.
Typed keys — new in 5.13
Section titled “Typed keys — new in 5.13”warlock dev watches route and module locales.json additions, edits,
deletions, and $group changes, then regenerates
.warlock/typings/translations.d.ts. warlock generate.typings does the same
for a non-development run. The declaration augments TranslationKeyRegistry
with flattened route JSON keys, module JSON keys, and existing literal
groupedTranslations(...) keys. Include .warlock/typings/**/*.d.ts
in the app TypeScript project. Runtime values still come from the selected
route artifact, never from this declaration file.
export type TranslationKey = keyof TranslationKeyRegistry extends never ? string : Extract<keyof TranslationKeyRegistry, string>;No app code needs to change — the registry is populated by the dev server, not by hand.
lang and dir on <html> — new in 5.12
Section titled “lang and dir on <html> — new in 5.12”The root document owns <html>, so it also owns lang and dir. Derive the
writing direction from the current locale with useTextDirection():
import { Head, Scripts, useLocale, useTextDirection } from "@warlock.js/web";import type { AppProps } from "@warlock.js/web";
export default function App({ children }: AppProps) { const locale = useLocale();
return ( <html lang={locale} dir={useTextDirection()}> <head> <Head /> </head> <body> <div id="vessel">{children}</div> <Scripts /> </body> </html> );}useTextDirection() derives "rtl" or "ltr" from the same locale
useLocale() exposes, via localeDirection() — a pure function safe to call
on the server or the client. No hydration payload change is involved: the
direction is computed from the locale that already made the trip, not
carried separately.
localeDirection(locale)
Section titled “localeDirection(locale)”import { localeDirection } from "@warlock.js/web";
localeDirection("ar"); // "rtl"localeDirection("en"); // "ltr"localeDirection("az-Arab"); // "rtl" — an explicit script subtag wins over the base languageCall it directly when you need a direction outside a component (for example,
in a loader). It never throws — an unparseable locale resolves to "ltr".
Switch locale without a reload — useChangeLocaleCode()
Section titled “Switch locale without a reload — useChangeLocaleCode()”import { useChangeLocaleCode, useLocale } from "@warlock.js/web";import { useState } from "react";
export function LocaleSwitcher() { const locale = useLocale(); const { changeLocaleCode, isLoading } = useChangeLocaleCode(); const [error, setError] = useState("");
const switchTo = async (code: string) => { setError(""); try { await changeLocaleCode(code); } catch { setError("Could not change language. Try again."); } };
return ( <> <select value={locale} disabled={isLoading} onChange={(event) => void switchTo(event.target.value)} > <option value="en">English</option> <option value="ar">العربية</option> </select> {error && <p role="alert">{error}</p>} </> );}useChangeLocaleCode() returns the local pending state for this picker and a
changeLocaleCode(code) function that rejects when a switch fails. Its
deprecated changeLocale alias remains available. The function re-fetches
the current route and swaps in the fresh page, the same way refresh() swaps
in fresh data.
useLocale(), useTextDirection(), and the root <html lang dir> all
follow from the new payload in that same render.
Render the picker in a layout or page component beneath #vessel, where
Warlock hydrates interactive components. Keep root.tsx as the document
shell. The imperative changeLocaleCode() export remains available when
the caller does not need the hook’s loading state.
With web.localeRouting.strategy: "none" (the default), the request uses a
provisional locale and the client persists a locale preference only after the
current navigation succeeds. Under "prefix-except-default" or "prefix",
the function changes the visible route to the selected locale prefix while
preserving query string and hash. Links and href() follow the same strategy.
A leading :locale page route — normally from a src/web/[locale]/ folder —
uses the URL segment without a global strategy. It fills a missing leading
locale parameter in links and swaps that segment on a locale change. Do not
enable a prefix strategy in an app with such a route: boot refuses
the combination because it would double-prefix URLs.
Calling it with the already-active locale is a no-op — no request is made. On failure (network error, or the fresh page could not be built), the promise rejects and the active locale and any cookie are left exactly as they were.
Translation delivery: shared tables and page pruning — new in 5.36
Section titled “Translation delivery: shared tables and page pruning — new in 5.36”A page’s hydration payload carries the translations its client code can reach, not every dictionary in the app. This works in both translation modes, and the two modes differ only in where the dictionary comes from:
- Route dictionaries (
locales.jsonundersrc/web), the scoped mode. - Global registry (
extend(),groupedTranslations(), theregister()hooks and the module dictionariessrc/app/<module>/utils/locales.jsonandutils/locales/<feature>.json), the default for a starter app. Per-feature files are merged into the registry before the tables are built, so their keys are shared, pruned and typed like any other.
The same two mechanisms apply to both:
- Shared groups (default
appandcommon, the first segment of a key) are shipped once per locale as a table whose URL carries its content hash, such as/__warlock/translations/en.3f9a1c0d2b7e4a51.json. The document preloads it, and hydration waits for it, so the first client render has every key. A table’s bytes never change under its URL, so a cached copy is never stale. - Every other group is pruned per page. The build (route dictionaries) or the server (global registry) reads the page’s client module graph, the page, its layouts, the root, every error page and the components they import, and inlines only the keys that graph can look up.
Keys are found statically:
| The client code does | The page ships |
|---|---|
trans("members.title"), t(...), transX(...) | that key and its subtree |
trans(`statuses.${order.status}`) | every key under the static prefix statuses. |
trans(KEY) where const KEY = "x.y" (same file) | x.y |
trans(flag ? "a" : "b") | both literal keys |
trans(key), a translator passed on as a value | every non-shared key (the full fallback, larger) |
A translator is any binding of trans, t, transX, transFrom’s key
argument, or the result of useTrans(), under any local name.
On top of the lookups it understands, the analysis counts every string literal
in the page’s client module graph. A literal that equals an existing key, names
a group or a subtree, or is the base of count variants is shipped, wherever it
is written: a constant, an object that maps names to keys, a prop
(<Stat labelKey="cart.title" />), an argument to your own helper, an array of
keys, or a component in another folder reached through a relative, web/ or
app/ import or an index barrel. The head of a template literal
(`cart.${section}`) counts as a prefix. Only literals that are shaped like
a key are considered: dotted words with no spaces, so prose, class lists and
import paths are ignored. A literal that matches no key ships nothing. So keys
you pass around as strings need no declaration; only keys built at run time do.
Count variants
Section titled “Count variants”A key used with a count is stored as suffixed variants
(items_zero, items_one, items_two, items_few, items_many, items_other,
items_negative or items_range_1_5), and the call names the base:
t("cart.items", { count }). Naming the base ships every variant of it, in both
translation modes, and the browser’s missing-key detector treats the base as
present when any variant arrived. A suffix is one segment, so cart.items
ships cart.items_one and not cart.itemsTotal or cart.items_one.note.
Declare what static analysis cannot see
Section titled “Declare what static analysis cannot see”A key computed at run time, with no static prefix and no literal anywhere in the graph to name it, makes the page fall back to every group. Declare the keys in the page’s setup file instead:
export const config = { route: "/orders", translations: ["statuses.*", "members.title", "members"],};"statuses.*" declares every key under statuses., "members.title" declares
one key, and a plain group name declares the group with its subtree. A
declaration may name a key the page reads only through a helper. warlock check
reports each computed lookup in src/web as WL9104 and points at this
declaration.
Global-registry apps
Section titled “Global-registry apps”In global mode the shared groups are copied once, when the web connector installs its routes at boot. That runs after the server bundle has evaluated the app’s module-level registrations, and before the server listens. The copy, its hash and the table that carries it are fixed for the life of the process:
- Every process started from one deployment copies the same groups, so each computes the same hash and serves the same bytes under it. A document from one process names a table every other process also holds.
- A registration that runs later, such as a page’s
register()hook on its first request, does not change the shared table. A key of a shared group that the page uses but the table lacks travels in that page’s payload like any other used key. A shared key the table already holds is never repeated in a payload. - A locale the registry did not hold at boot gets an empty shared table. Its keys travel inline, the same way.
In development the table is recomputed from the registry for every payload and
republished when it changes (no-store), so an edit shows at once.
The client merges the shared table and each page’s pruned keys into the
registry. A later navigation adds its page’s keys and keeps the earlier ones.
trans() imported from @mongez/localization sees the keys the page received.
Serving the tables and the fallback order
Section titled “Serving the tables and the fallback order”The server answers under /__warlock/translations/:
<locale>.<hash>.jsonanswers that exact table. In production it is markedCache-Control: public, max-age=31536000, immutable, since the same name always means the same bytes. In development it isno-store, and it carries every group, so the missing-key detector can read it.<locale>.jsonanswers the newest table the process holds for the locale, markedno-cache, because it changes when the table does.- A name the server does not hold answers 404 with
no-store. It never answers different bytes under the same name.
A payload names the hashed table and the unhashed latest one. The browser loads the first table that answers, in this order:
- The hashed table the payload names.
- Only when step 1 answered 404 (the name is not in this process, because another process of the deployment or an older deployment served the page): the unhashed latest table.
- Otherwise, or when step 2 also fails: the hashed table once more.
- When that also fails: the failure is reported once through
onClientErrorwithkind: "translations", and the page renders its keys. It never renders a blank string.
Route-dictionary apps follow the same order. Their hashed table is the build’s
file under /assets/translations/. A process pins the same table under the same
hash at boot, so the unhashed latest table answers in every process as well.
Production tables are fixed for the process. A process therefore answers every hashed name it published, and the latest table, which covers a name from another process. A deployment should run every process from the same build, so that the tables match.
Development and production behave differently on a miss
Section titled “Development and production behave differently on a miss”A key the page reads but its payload lacks is a pruning miss:
- Development resolves it from the full table, so the page keeps working, and logs one warning per key and page that names the declaration to add.
- Production renders the key and reports the miss once through the client
error reporter that
onClientErrorregisters, the same one hydration and navigation failures use. The report haskind: "translations".
If the shared table cannot be loaded even by the order above, the failure is
reported once through onClientError with kind: "translations", and the page
renders its keys.
In global mode a direct call to trans() from @mongez/localization is not
hooked, so a pruning miss there is not reported. Calls through useTrans()
and the framework’s own translator are checked.
Options and defaults
Section titled “Options and defaults”The translations option is set on the web connector’s build settings, and the
same object reaches the production build and the development server:
web({ build: { translations: { sharedGroups: ["app", "common", "members"], // shipped once per locale prune: true, // false ships every non-shared key on every page }, },});| Option | Default | Route dictionaries (scoped) | Global registry |
|---|---|---|---|
sharedGroups | ["app", "common"] | shared table emitted at build time | shared table copied at boot, fixed for the process |
prune | true | keys pruned to the page’s usage | keys pruned to the page’s usage, and to the snapshot |
prune: false ships every non-shared key on every page, as payloads did before
5.36. Use it to check a page whose keys you have not declared yet. Development
honours both options, so a page behaves the same way in warlock dev and in
production. The missing-key detector still makes a pruning miss visible in
development.
Intl labels and hydration
Section titled “Intl labels and hydration”Labels from Intl.DisplayNames, Intl.Collator or localeCompare sorting are
computed from the ICU data of the runtime that evaluates them. Node and each
browser ship different data, so a region name (Hong Kong, Macao, Palestine and
others) or a sort order can differ between the server render and the browser,
and React reports a hydration mismatch (error 418 in production).
Render such text from a table both sides share: a checked-in, pre-sorted list
of names per locale, or a value computed in the loader and returned as page
data. If the browser must compute it, render it after hydration, for example in
an effect, or inside <ClientOnly>.
In development, when React reports a hydration mismatch, the client logs the first mismatching node with the element path above it, the server text and the client text. Production builds log nothing extra.
Gotchas
Section titled “Gotchas”useLocale()requires the page pipeline. It throws outsideLocaleProvider.localeDirection()is pure. It never reads ambient state and never throws; an unparseable locale resolves to"ltr".- The URL strategy decides URL changes.
"none"keeps the current path; prefix routing and a leading:localeroute change it.
See also
Section titled “See also”- Loaders and metadata —
reading request-scoped values, including locale, through
shared.