Everything below imports from @foony/translate/react unless noted. The runtime is small on purpose: it looks up a hash in the JSON you passed to the provider and renders the result. No locale state, no fetches.
TranslateProvider
Supplies the locale and the parsed locale JSON to everything below.
<TranslateProvider locale="fr" translations={fr}>
<App />
</TranslateProvider>
locale: any BCP 47 code. DrivesIntlformatting and CLDR plural rules.translations: the parsed locale JSON from the CLI. Omit it (or omit the provider entirely) and everything renders the English source.dev:{ apiKey }enables on-demand translation of missing entries while developing. Gate it withimport.meta.env.DEVso the whole path is dead code in production builds.
useLocale() returns the active locale ('en' with no provider).
T
Marks static JSX for translation. Children are serialized and hashed; the hash looks up the translated structure in the locale JSON. On a miss the English source renders unchanged.
<T context="verb on a button, not a noun">Save</T>
id: a stable lookup alias. It is never part of the hash, so adding one never re-translates.context: disambiguation for the translator, part of the hash (same text with different context is a different entry).
Dynamic values must be wrapped in <Var>/<Num>/<DateTime>, and conditional content in <Branch>/<Plural>. The CLI enforces this with file:line errors, including on ternaries inside <T>.
Var, Num, DateTime
Mark dynamic slots inside <T>. The value serializes to a named placeholder and is re-inserted after translation, so its contents never leave your app.
<T>
<Var name="user">{user.name}</Var> joined{' '}
<DateTime name="when" options={{ dateStyle: 'medium' }}>{joinedAt}</DateTime>{' '}
and has <Num name="points">{points}</Num> points.
</T>
<Num>formats withIntl.NumberFormatfor the provider locale. Useoptions={{ style: 'currency', currency: 'USD' }}for money.<DateTime>formats withIntl.DateTimeFormat.- All three also work standalone, outside
<T>.
Branch and Plural
Conditional content inside <T>: every possible variant is declared as a prop, so the translation carries all branches and the runtime picks one.
<T>
Your key is <Branch branch={status} active="ready to use" revoked="no longer valid" />.
</T>
<T>
You have <Plural n={count} zero="no unread messages" one="one unread message"
other={<><Num name="count">{count}</Num> unread messages</>} />.
</T>
<Branch branch={value}>matchesString(value)against its props;childrenis the fallback.<Plural n={count}>picks a CLDR plural category (zero,one,two,few,many,other) for the locale. An explicitzerowins forn === 0even in English. Translations may carry categories the English source lacks, likefew/manyfor Polish or Russian.
The gotcha that matters: any text whose grammar depends on the branch must live inside the options, not around them. You have <Plural n={n} one="1" other={n} /> messages is wrong, because “messages” needs to inflect with the count in most languages, and the translator only sees the branch content. Put the whole phrase in each option: one="one message" other="... messages".
useT
String translation for non-JSX copy: placeholders, aria-labels, titles, toasts.
const t = useT();
t('Save changes');
t('Hello, {{name}}!', { name });
t('Delete?', undefined, { context: 'Confirm dialog title' });
Messages must be static string literals (the CLI extracts them at build time and errors on dynamic values). {{name}} placeholders interpolate from the second argument. Identical message + context anywhere in the codebase shares one translation.
Dictionaries: defineDict, createDict, useDict
For copy that lives in data rather than JSX, define a nested object of English strings. Entry ids are dot-paths; JSDoc comments on entries and their ancestors become translator context. Write the comment once and every leaf under it is translated with that context.
import { defineDict } from '@foony/translate';
/** Labels for the billing settings screen. */
export const billingDict = defineDict({
plan: {
/** Button that opens Stripe's checkout. */
upgrade: 'Upgrade plan',
usage: 'You have used {{words}} words this month.',
},
});
In React, useDict binds the dictionary to the provider’s locale:
const d = useDict(billingDict);
d('plan.upgrade');
d('plan.usage', { words });
Outside React (Astro frontmatter, server code), createDict(dictionary, locale, translations) returns the same lookup function with no hooks involved.
Because the translator context comes from JSDoc, dictionary entries are the one thing dev mode cannot translate on demand: the runtime cannot see comments. They translate on the next CLI run. List dictionary files under dictionaries in foony-translate.json.
Dev mode
<TranslateProvider
locale={locale}
translations={translations}
dev={import.meta.env.DEV ? { apiKey: import.meta.env.PUBLIC_FOONY_TRANSLATE_DEV_KEY } : undefined}
>
With a dev key, missing entries are translated on demand while you develop and swap in place when ready. Dev keys are rate-limited and meant for local machines only; production builds should ship committed JSON and no key.