
Company News
Free Business Plan Upgrades for Open Source Maintainers
Open source maintainers are under more pressure than ever. We're raising our open source program from the Team plan to the Business plan, free.
@sonenta/react-i18next
Advanced tools
The React SDK for Sonenta. Resolve translations from the Sonenta CDN, fall back gracefully when a key is missing, and stream those missing keys back to your dashboard in real time so the team can fill them without redeploying.
npm install @sonenta/react-i18next
t() + <Trans> semantics — drop-in for most i18next codebasesAlready on react-i18next? Switch in two steps. Your existing
useTranslation, t(), and <Trans> calls keep working unchanged.
1. Repoint the import — one find-and-replace across your codebase:
- import { useTranslation, Trans } from "react-i18next";
+ import { useTranslation, Trans } from "@sonenta/react-i18next";
2. Wrap your app once in <SonentaProvider> (replacing i18next.init +
I18nextProvider). Translations now load from the Sonenta CDN and missing
keys stream to your dashboard — everything else stays the same:
import { SonentaProvider } from "@sonenta/react-i18next";
<SonentaProvider token={…} projectUuid={…} defaultLocale="fr" fallbackLng="en">
<App />
</SonentaProvider>
No i18next.init, no backend module, no bundled JSON. See
Migrating from react-i18next for the full
compatibility notes.
import { SonentaProvider, useTranslation } from "@sonenta/react-i18next";
export function App() {
return (
<SonentaProvider
token={import.meta.env.VITE_SONENTA_TOKEN}
projectUuid={import.meta.env.VITE_SONENTA_PROJECT}
defaultLocale="fr"
fallbackLng="en"
namespaces={["common"]}
>
<Hello />
</SonentaProvider>
);
}
function Hello() {
const { t, i18n } = useTranslation("common");
if (!i18n.ready) return <span>Loading…</span>;
return <h1>{t("hello.title", { name: "Marc", defaultValue: "Hello {{name}}" })}</h1>;
}
The token is the API key minted in Org Settings → API Keys. For the
browser SDK use a project-scoped key with the missing:write scope and
nothing else — that key only sees missing-key writes for one project, which
is the safest exposure profile.
SonentaProviderinterface SonentaConfig {
token: string; // vrb_live_<prefix>.<secret>
projectUuid: string;
defaultLocale: string; // BCP-47 (e.g. "fr", "fr-CA")
fallbackLng?: string | string[]; // fallback locale(s); variants also fall back to their base (fr-CA → fr)
namespaces?: string[]; // default ['common']
defaultNS?: string; // alias: default namespace for single-ns apps
apiBase?: string; // default 'https://api.sonenta.com'
cdnBase?: string; // default 'https://cdn.sonenta.com'
languageCatalog?: LanguageMeta[]; // embed the language catalog (offline/SSR/RN); powers dir()/nativeName()
disableLanguageCatalog?: boolean; // skip the public GET /v1/languages fetch
version?: string; // version slug, default 'main' (in cache keys)
versionSlug?: string; // @deprecated alias of `version`
env?: 'prod' | 'dev'; // default 'prod' (drives fetch source)
keySeparator?: string | false; // false = flat (literal keys); else split (default '.'); auto-detected from the version when omitted
nsSeparator?: string | false; // 'ns:key' separator (default ':'); false to disable (keys may contain ':')
initialBundles?: Record<string, Record<string, object>>; // build-time snapshot (locale->ns->tree)
plugins?: SonentaPlugin[]; // e.g. @sonenta/feedback, @sonenta/realtime
transport?: (batch: MissingKeyEvent[]) => void | Promise<void>;
missingHandler?: 'send' | 'log' | 'off'; // default 'send'
flushIntervalMs?: number; // default 5000
flushBatchSize?: number; // default 50
missingEventsBufferSize?: number; // default 200
}
version selects which published version's bundles to load
(/p/<project>/<version>/latest/...); it defaults to 'main' and is part
of the SDK's bundle cache keys, so two providers with different version
values never share cached bundles. versionSlug is a deprecated alias
of version (if both are set, version wins).
Removed in 0.9.0: the
liveUpdates/centrifugoWsUrl/centrifugoTokenEndpointconfig keys. Realtime updates now live in the separate@sonenta/realtimeplugin (see Realtime updates). Passing any of those keys throws a clear migration error.
### `useTranslation(defaultNamespace?)`
Returns `{ t, i18n }`.
```ts
// Two call shapes — the native object form AND the react-i18next-style
// positional fallback (a string 2nd arg is the default value):
type TranslationFunction = {
(key: string, defaultValue: string, options?: Record<string, unknown>): string;
(key: string, options?: Record<string, unknown> & { defaultValue?: string }): string;
};
interface I18nInstance {
ready: boolean;
locale: string;
language: string; // alias of `locale`
setLocale(next: string): Promise<void>;
changeLanguage(next: string): Promise<void>; // alias of `setLocale`
t: TranslationFunction; // for out-of-React use via getI18n()
missingEvents: MissingKeyEvent[]; // newest first, capped buffer
flushMissing(): Promise<void>; // force-flush the pending batch
reload(opts?: { locale?: string; namespace?: string }): Promise<void>;
dir(lng?: string): 'ltr' | 'rtl'; // text direction (i18next parity); default active locale
nativeName(lng?: string): string | undefined; // endonym fallback when no Intl.DisplayNames
languageMeta(lng?: string): LanguageMeta | undefined; // full catalog entry
}
i18n.reload(opts?)Bust-refetches already-loaded bundles (bypassing the browser HTTP cache)
and re-renders. Without opts it refreshes every loaded (locale, ns)
bundle; pass { locale } and/or { namespace } to narrow. Returns once
all refetches settle. Useful for a manual "refresh translations" button,
and it's what @sonenta/realtime calls on a
translations_published push.
Variant fallback chain. An active regional variant resolves through its
base language automatically before the configured fallbackLng — the
fr-CA → fr → source chain (native i18next semantics; multi-subtag locales
truncate progressively, e.g. zh-Hant-TW → zh-Hant → zh). Set fallbackLng
to your project's source language to terminate the chain there:
<SonentaProvider {...config} defaultLocale="fr-CA" fallbackLng="en">
The CDN already serves a variant as a fully merged bundle, so this is
defense-in-depth (and it also covers keys you serve from initialBundles).
fallbackLng also accepts an ordered chain: fallbackLng={['fr', 'en']}.
Direction (RTL). i18n.dir(lng?) returns 'ltr' | 'rtl' for a locale
(default: active), so you can drive <html dir> or a container's dir:
const { i18n } = useTranslation();
useEffect(() => { document.documentElement.dir = i18n.dir(); }, [i18n.language]);
It reads the public language catalog's rtl (variants inherit from their base),
falling back to a built-in RTL-language list before/without the catalog.
Native names. i18n.nativeName(lng?) returns a language's endonym (e.g.
français (Canada)) — the fallback for runtimes without
Intl.DisplayNames (React Native/Hermes, SSR). For UI-localized names,
prefer Intl.DisplayNames(uiLocale, { type: 'language' }).of(code) and fall
back to nativeName() when it is unavailable. i18n.languageMeta(lng?)
returns the full catalog entry (rtl, script, parent_code,
plural_categories, …).
These read a small public catalog (GET {apiBase}/v1/languages, no auth,
CDN-cached) fetched best-effort on start(). Embed it with
languageCatalog={[…]} for offline/SSR/React Native, or skip the fetch with
disableLanguageCatalog.
await getI18n().reload(); // refresh all
await getI18n().reload({ locale: "fr", namespace: "common" });
<Trans>Inline translation with JSX slots:
<Trans
i18nKey="cta.terms"
defaults="I accept the <0>terms</0> and <1>privacy policy</1>"
components={[<a href="/terms" />, <a href="/privacy" />]}
/>
The <0>...</0> slots are 0-indexed into components. The bundle string
should follow the same shape so that translators see I accept the <0>terms</0>...
and the SDK swaps the elements at render time.
@sonenta/react-i18next is built to be a near drop-in for react-i18next, so
existing codebases migrate with minimal changes:
Positional default value — t('key', 'Default text') works (so does
t('key', 'Hi {{name}}', { name })), alongside the native
t('key', { defaultValue }). No codemod needed for inline fallbacks.
changeLanguage / language — i18n.changeLanguage('en') (alias of
setLocale) and the i18n.language getter (alias of locale) are available.
Out-of-React access — getI18n() returns the active instance for use in
plain modules, stores, or helpers (the react-i18next standalone-singleton
pattern):
import { getI18n } from "@sonenta/react-i18next";
// anywhere after <SonentaProvider> has mounted:
const label = getI18n().t("nav.home", "Home");
await getI18n().changeLanguage("en");
getI18n() throws a clear error if no provider is mounted yet, and assumes a
single app-wide provider.
Default namespace — the default is ['common'] (not react-i18next's
'translation'). Migrants pass namespaces={['translation']}, or the
defaultNS="translation" alias for single-namespace apps.
Plurals and context are not resolved yet: t('key', { count }) performs
interpolation only — it does not select plural keys (key_one /
key_other) or context keys (key_male). Handle these manually until V1.1.
@sonenta/feedback (1.0.3+)The drop-in path lets hosts keep their from 'react-i18next' imports
verbatim — the shared i18next instance resolves them correctly. From
1.0.3 onwards, those native calls also feed the on-screen key registry
that @sonenta/feedback reads, so the widget lists the strings rendered
on the current view in either import shape:
// works (sonenta hook — strict per-view drop semantics)
import { useTranslation } from "@sonenta/react-i18next";
// also works (native hook — registry fed via the instance-level wrap)
import { useTranslation } from "react-i18next";
The trade-off for the native path: the registry accumulates for the i18n instance's lifetime (no per-component unmount signal to drop from), so after several view changes the widget may list a few extra keys from prior views. A strictly empty widget on a populated view is no longer possible. Hosts that want strict per-view scoping can either:
@sonenta/react-i18next's useTranslation (mount-tracked, drops
on unmount), via the codemod below;keyRegistry.reset() from their router on navigation.Codemod — switch all imports in one shot (re-run as needed):
grep -rl "from 'react-i18next'" src \
| xargs sed -i '' "s|from 'react-i18next'|from '@sonenta/react-i18next'|g"
Dev-time assertion — confirm wiring at boot:
import { keyRegistry } from "@sonenta/react-i18next";
if (__DEV__ && !keyRegistry.isPopulated()) {
console.warn(
"@sonenta: no on-screen keys yet — render a screen with t() first, " +
"or check that your useTranslation imports resolve to @sonenta/react-i18next " +
"(or to the native react-i18next bound to the exposed i18next instance).",
);
}
By default keys are nested and split on . — t("hero.title") reads
{ hero: { title } }. If your project stores flat keys (literal keys that
may contain dots, e.g. "App Version 6.3.8"), set keySeparator={false} so
keys are looked up verbatim:
<SonentaProvider {...config} keySeparator={false}>
keySeparator={false} — flat (literal keys; dotted keys work, never split).keySeparator="." (default) or any string — nested, split on it.key_style / key_separator
from the version metadata on mount (best-effort; needs an API key with
project:read, otherwise it falls back to nested "."). Set keySeparator
explicitly to skip that lookup and guarantee the style.Resolution is literal-first: an exact bundle[key] always wins, so a dotted
key resolves even in nested mode without config (the nested split is the
fallback). The namespace separator is configurable too — nsSeparator (default
":"; false disables "ns:key" parsing so keys may contain ":").
t("hello.title").(locale, namespace) was already fetched but doesn't
contain hello.title. (i18n.ready === true and the bundle for that
tuple is in the "attempted" set — this is the gate.)MissingKeyEvent, dedups it within the instance, and
pushes it into the missingEvents ring buffer.flushIntervalMs (default 5s) — or sooner if the batch hits
flushBatchSize (default 50) — the SDK flushes the pending batch via
the transport.interface MissingKeyEvent {
key: string;
namespace: string;
language_code: string;
source_value?: string; // explicit defaultValue or fallback value; omitted when none (never the key name)
sdk_meta?: Record<string, unknown>; // SDK adds {lib, ver, url} automatically
}
source_value carries the canonical default the SDK has — the defaultValue
you pass to t() (object or positional form), or the fallback-language bundle
value. When there is no default, it is omitted (the key name is in key),
so the backend never mistakes a key for a translation.
Without the gate, every t("…") call between mount and bundle resolution
would report a "missing" key — which is a lie (the bundle just hadn't
arrived yet). The first-paint flood would poison your dashboard. The SDK
holds reports until both:
i18n.ready === true (initial bundles loaded), AND(locale, namespace) bundle was actually fetched.You can see the gate in action with i18n.missingEvents — it stays empty
until the network round-trip completes.
Replace the default POST with anything — Storybook mock, in-app inspector, Cypress capture:
<SonentaProvider
{...config}
transport={(batch) => {
window.parent.postMessage({ type: "sonenta:missing", batch }, "*");
}}
>
...
</SonentaProvider>
The default delivery path is also exported if you need to wrap it:
import { defaultTransport, logTransport } from "@sonenta/react-i18next";
Zero-deploy translation updates (subscribe to the project's Centrifugo
translations: channel and bust-refetch on publish) live in the separate
@sonenta/realtime package — added as a plugin of this
provider, not configured here:
import { SonentaProvider } from "@sonenta/react-i18next";
import { sonentaRealtime } from "@sonenta/realtime/react";
<SonentaProvider
{...config}
env="dev"
plugins={[
sonentaRealtime({ wsUrl: "wss://rt.sonenta.dev/connection/websocket" }),
]}
>
<App />
</SonentaProvider>;
Under the hood the plugin calls i18n.reload(...) on
each translations_published push. Realtime is a dev-version-only feature
(it only subscribes when env: "dev").
The
liveUpdates/centrifugoWsUrl/centrifugoTokenEndpointconfig keys were removed in 0.9.0. Install@sonenta/realtimeand use the plugin instead.
Native apps (and SSR/web) can render real translations on the first paint and offline — before the first CDN fetch — by embedding a build-time snapshot:
import snapshot from "./sonenta-snapshot.json"; // { locale: { namespace: tree } }
<SonentaProvider {...config} initialBundles={snapshot}>
<App />
</SonentaProvider>;
initialBundles is keyed locale -> namespace -> tree (the same shape as the
CDN JSON). It is primed synchronously, so i18n.ready is true on the very
first render when the snapshot covers the active locale's namespaces. On mount
the provider fetches the CDN and swaps in fresh values with no flash; if
that fetch fails (offline), the snapshot stays as last-known-good. Keys absent
from the snapshot do not fire "missing" reports until a real fetch confirms.
verbumia snapshot (from @verbumia/cli) fetches the
current published bundles and writes the JSON module.https://cdn.sonenta.com/p/<project>/<version>/latest/<locale>/<ns>.json and
assemble them into { [locale]: { [namespace]: <tree> } }, then import it.Render surface-specific copy (desktop / mobile / tablet) on top of your
normal locale resolution. A base bundle applies to every surface; a sparse
surface overlay ({ns}.{surface}.json on the CDN) overrides individual
keys for that surface only. t() returns the overlay value when present, else
the base — composing cleanly with locale fallback and plurals.
// Initial surface + reactive viewport detection (web):
<SonentaProvider {...config} surface="desktop" surfaceBreakpoints={true}>
<App />
</SonentaProvider>;
surface sets the initial surface (omit it to disable surface resolution
entirely — fully back-compatible).
surfaceBreakpoints={true} enables reactive detection from the viewport on
web (the provider maps window.innerWidth → surface via matchMedia and
calls setSurface on boundary crossings). Pass custom thresholds with
surfaceBreakpoints={{ mobile: 640, tablet: 1024 }}.
React Native (no window): set the initial surface, then drive changes
yourself from useWindowDimensions:
import { surfaceForWidth } from "@sonenta/react-i18next";
const { width } = useWindowDimensions();
useEffect(() => { i18n.setSurface(surfaceForWidth(width)); }, [width]);
Imperative: i18n.surface, i18n.setSurface("mobile").
Asset variants (minimal v1): an overlay key may carry
{ "$value": "...", "$asset": { kind, ref } }. t(key) returns $value;
read the companion ref with i18n.asset(key, ns?).
Plurals work the same in overlays (single key + CLDR plural forms); an overlay
plural set fully replaces the base key's. Surface overlays are served from the
CDN; env: "dev" is base-only for now.
A11y variants attach SEMANTIC accessibility text to a key — aria_label,
alt_text, screen_reader, plain_language — delivered through the same
sparse-overlay engine as device surfaces, but applied orthogonally to the
visible text (an element has both its visible label AND an accessible name).
Opt in per surface; they load alongside the base bundles.
<SonentaProvider {...config} a11ySurfaces={["aria_label", "alt_text"]}>
<App />
</SonentaProvider>
function SaveButton() {
const { t } = useTranslation("common");
return (
<button aria-label={t.aria("save")}>{t("save")}</button>
);
// t("save") → visible text ("Save")
// t.aria("save") → aria_label overlay, or the visible text if no override
}
function Hero() {
const { i18n } = useTranslation();
return <img src={i18n.a11yAsset("hero")?.ref} alt={i18n.alt("hero")} />;
}
t.aria(key) / t.alt(key) — overlay value, falling back to the visible
text when no a11y override exists. Also on the instance: i18n.aria() /
i18n.alt().t.a11y(key, surface) / i18n.a11y(key, surface) — the raw resolver:
returns undefined when there's no override (use for screen_reader /
plain_language, which should be omitted rather than fall back).i18n.a11yAsset(key) — the alt_text overlay's localized-image $asset.plain_language (or pass
plainLanguage) and call i18n.setPlainLanguage(true) — t() then returns
the plain_language overlay for keys that have one (else the base text).Resolution is locale-outer / surface-inner (same chain as t()): (fr-CA, aria_label) > (fr-CA, base) > (fr, aria_label) > (fr, base). Overlays are
CDN-only (env: "dev" is base-only).
Keys carry a semantic type (image, icon, button, text, …). You
pick the helper by the element you render — the SDK resolves the right
value automatically (overlay ?? base), so you don't need the type at runtime:
<img alt={t.alt("hero_image")} /> {/* image: base IS the alt */}
<button aria-label={t.aria("save_icon")}> {/* icon: base IS the accessible name */}
<button aria-label={t.aria("submit")}>OK {/* button: aria_label refines the label */}
image → the base value is the alt; t.alt(key) returns it.icon → the base value is the accessible name; t.aria(key) returns it.button / link / inputs → t.aria(key) returns the aria_label refinement
(falling back to the visible label).plain_language / screen_reader treatments apply; the backend
only publishes the treatments relevant to a key's type, so nothing else
resolves.For type-aware UIs (e.g. an editor), the capability map is exported:
A11Y_TREATMENTS_FOR, treatmentsFor(type), baseRoleFor(type), and the
KeyType union — mirroring the backend.
List the languages published for the active version at runtime — so adding
a language in Sonenta makes it appear in your switcher without recompiling
the app. The set comes from a per-version CDN manifest (public, cacheable,
no auth); each code is enriched from the language catalog (native_name,
rtl, …).
import { useTranslation, useAvailableLanguages } from "@sonenta/react-i18next";
function LanguageSwitcher() {
const { i18n } = useTranslation();
const languages = useAvailableLanguages(); // [{ code, native_name, rtl, is_default, published_at? }]
return (
<select value={i18n.language} onChange={(e) => i18n.setLocale(e.target.value)}>
{languages.map((l) => (
<option key={l.code} value={l.code}>
{l.native_name ?? l.code}
</option>
))}
</select>
);
}
i18n.reload() — no rebuild, no redeploy
of your app.is_default marks the project's default locale; published_at (when the
manifest provides it) lets you flag recently-added languages.defaultLocale + fallbackLng when no manifest is available
(or env: "dev", which is CDN-only). Opt out with disableLanguageManifest.i18n.availableLanguages.Wrap the SDK in a Client Component and feed it env vars from .env.local:
// app/(sonenta)/i18n-client.tsx
"use client";
import { SonentaProvider } from "@sonenta/react-i18next";
export function I18nClient({ children }: { children: React.ReactNode }) {
return (
<SonentaProvider
token={process.env.NEXT_PUBLIC_SONENTA_TOKEN!}
projectUuid={process.env.NEXT_PUBLIC_SONENTA_PROJECT!}
defaultLocale="fr"
fallbackLng="en"
>
{children}
</SonentaProvider>
);
}
The provider reads the bundle via the public CDN — no server-side state to
hydrate. SSR pre-renders the defaultValue and the client smoothly
upgrades after i18n.ready flips.
// .storybook/preview.tsx
import { SonentaProvider } from "@sonenta/react-i18next";
export const decorators = [
(Story) => (
<SonentaProvider
token="vrb_live_storybook.fake"
projectUuid="storybook"
defaultLocale="fr"
missingHandler="log"
transport={(batch) => action("missing-keys")(batch)}
>
<Story />
</SonentaProvider>
),
];
cy.intercept("POST", "**/v1/missing", (req) => {
cy.task("captureMissing", req.body);
req.reply({ accepted: req.body.events.length, rejected: 0, items: [] });
});
Semver. V1.x will keep the public API stable. Internal changes (bundle fetcher, dedup heuristics) may shift in patch releases.
Breaking changes pre-V1 are flagged in CONTRACT.md.
MIT — see LICENSE.
FAQs
React SDK for Sonenta: translations + realtime missing-key handler.
The npm package @sonenta/react-i18next receives a total of 178 weekly downloads. As such, @sonenta/react-i18next popularity was classified as not popular.
We found that @sonenta/react-i18next demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Company News
Open source maintainers are under more pressure than ever. We're raising our open source program from the Team plan to the Business plan, free.

Security News
The supply chain control that delays freshly published gems now covers lockfile generation and gem vendoring in Ruby projects.

Security News
During a UK cyber test, a Mythos 5 agent used sockpuppets, social engineering, and prompt injection to try to get a maintainer to merge malware.