4 min read
Admin languages
The admin speaks English and Hindi. English stays the source: every string still lives in
apps/admin/src/data/<feature>.ts. Hindi is a typed overlay in data/hi/<feature>.ts merged
over the English key by key, so a page (or a single key) with no Hindi shows English and
nothing breaks.
No library: the copy is plain TypeScript objects with functions in them ((n) => ...), which
ICU-message libraries such as next-intl (MIT) would have meant rewriting. The layer is about
150 lines in apps/admin/src/lib/i18n.ts and lib/copy.ts.
How a request gets its language
The root layout reads the cookie on the server, so the first paint is already in the right language: no flash, nothing for hydration to disagree with. There is no database column; the choice is per browser, like the theme mode.
Hindi loads only for Hindi members
The Hindi overlays are about 200 kB gzip, so they are not part of any page’s JavaScript.
lib/copy.ts starts with no translations; loadHindi() (lib/hindi.ts) imports data/hi as
its own chunk and registers it, once:
- Server:
getLocale()awaitsloadHindi()when the cookie sayshi, so everything that localizes with that locale (getCopy,localize, the root layout) answers in Hindi. - Browser:
LocaleProvider(root layout) suspends onloadHindi()when the locale ishiand the chunk is not loaded yet; nothing below it renders English first. English members never fetch the chunk.
So never import @/data/hi (the registry) from a component or a shared module: that would put
every overlay back into every page. Tests that need Hindi call
addTranslations(hindiCopy) from lib/copy.ts first (lib/i18n.test.ts). data/hi/navigation.ts
(page names, about 3 kB) is the one overlay imported directly, by lib/page-names.ts.
| Piece | File |
|---|---|
Locales, cookie, Translation<T> type, mergeCopy, untranslated | lib/i18n.ts (pure) |
| Registry of Hindi files | data/hi/index.ts (loaded on demand by lib/hindi.ts) |
localize(en, locale) (cached per export) | lib/copy.ts |
getLocale(), getCopy(en) for server components | lib/copy-server.ts |
useLocale(), useCopy(en), useFormat() for client components | hooks/use-copy.ts |
| Money, numbers, dates per language | lib/format.ts |
| Page names by route (sidebar, top bar, settings rail) | data/hi/navigation.ts, lib/page-names.ts |
| Status badge labels | lib/status-labels.ts |
| Word list | docs/i18n-glossary.md |
Reading copy in a component
// client component
const copy = useCopy(ordersContent)
const fmt = useFormat()
return <p>{copy.summary(count)} · {fmt.money(totalPaise)}</p>
// server component
const copy = await getCopy(ordersContent)
const fmt = formatters(await getLocale())Module-scope helpers and .ts files take the copy (or a locale) as a parameter:
totalRows(order, copy, fmt), rateErrors(copy, rate). Don’t keep translated text in state;
keep the code ('invalid') and look the text up at render, so switching language updates it.
A component that still imports ordersContent and reads it directly keeps working; it just
shows English.
Adding copy
Add the English key to data/<feature>.ts as usual. If that file has a Hindi twin, the
typecheck then fails on data/hi/<feature>.ts until the key is there too: the Translation<T>
type requires every key, rejects keys the English does not have, and requires functions to take
the same parameters. That is deliberate, so nothing goes missing silently. If you can’t write
the Hindi, say so explicitly:
import { translation, untranslated } from '@/lib/i18n'
translation(ordersContent, {
// ...
newBanner: untranslated, // shows English until someone translates it
})rg untranslated apps/admin/src/data/hi lists them; the i18n test prints the count.
A new data/<feature>.ts with no Hindi file needs nothing: it stays English.
Translating a feature
-
Create
data/hi/<feature>.ts:import { ordersContent } from '@/data/orders' import { translation } from '@/lib/i18n' export default [ translation(ordersContent, { title: 'ऑर्डर', count: (n) => `${n} ऑर्डर`, // every key; ids and hrefs copied as they are }), ] -
Spread it into
hindiCopyindata/hi/index.ts. -
Move the components that read it onto
useCopy/getCopy.scripts/i18n-codemod.py <file> <exportName>does the mechanical part for client components. -
Follow
docs/i18n-glossary.md: plain everyday Hindi, and the English words merchants actually say (order as ऑर्डर, COD, GST, SKU, UPI, pincode, brand names in Latin). -
Run
bun run --cwd apps/admin test(the i18n test checks for unknown keys) and the typecheck.
Keep data/*.ts free of imports from lib/copy, lib/format*, lib/page-names and
lib/status-labels: those import the Hindi registry, which imports the data files, and the
cycle breaks at module load. A test enforces this.
Money, numbers and dates
useFormat() returns formatters for the language. Both languages keep Indian lakh/crore
grouping and Latin digits (₹1,23,456), through @commerce/money with en-IN or hi-IN.
Compact amounts differ: English ₹1.2L / ₹1.2Cr, Hindi ₹1.2 लाख / ₹1.2 करोड़ (the
package spells the units out and stays in crore instead of hi-IN’s own अरब). Dates use
hi-IN with full month names (12 सितंबर 2026), still pinned to the store’s zone
(Asia/Kolkata) through formatInZone in @commerce/dates; relative days (आज, कल,
5 मिनट पहले) come from data/dates.ts. Need another pattern?
useFormat().pattern(value, { ... }), never a hand-built Intl.DateTimeFormat (the date-format sweep fails on one).
Page names and status badges
Sidebar labels, the top bar title and the settings rail are looked up by route in
hindiPageNames (data/hi/navigation.ts), so reordering or adding nav items never shifts a
label; a route with no entry keeps its English name. Status badges keep data/status.ts as it
is: StatusLabel finds the badge’s English label in the orders, order detail, shipment,
products and home copy and shows the text at the same key of the Hindi.