npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sonenta/react-i18next

v2.4.0

Published

React SDK for Sonenta — translations + realtime missing-key handler.

Downloads

1,212

Readme

@sonenta/react-i18next

MIT licensed

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
  • ✦ Zero-config CDN fetch (Bunny.net edge)
  • ✦ Built-in missing-key handler with first-paint anti-spam gate
  • ✦ Pluggable transport for Storybook / inspectors
  • ✦ < 10 KB ESM (gzipped much smaller), tree-shakeable
  • ✦ Plain t() + <Trans> semantics — drop-in for most i18next codebases

Drop-in replacement for react-i18next

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


Quickstart

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.


API surface

SonentaProvider

interface 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 / centrifugoTokenEndpoint config keys. Realtime updates now live in the separate @sonenta/realtime plugin (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.

Regional variants, direction & native names

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.


Migrating from react-i18next

@sonenta/react-i18next is built to be a near drop-in for react-i18next, so existing codebases migrate with minimal changes:

  • Positional default valuet('key', 'Default text') works (so does t('key', 'Hi {{name}}', { name })), alongside the native t('key', { defaultValue }). No codemod needed for inline fallbacks.

  • changeLanguage / languagei18n.changeLanguage('en') (alias of setLocale) and the i18n.language getter (alias of locale) are available.

  • Out-of-React accessgetI18n() 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.

Not yet supported (planned for V1.1)

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.

Drop-in imports + @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:

  • adopt @sonenta/react-i18next's useTranslation (mount-tracked, drops on unmount), via the codemod below;
  • or call 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).",
  );
}

Flat vs nested keys

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.
  • Omitted — the SDK auto-detects the project's 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 ":").

Missing-key flow

  1. The user navigates a page that calls t("hello.title").
  2. The bundle for (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.)
  3. The SDK enqueues a MissingKeyEvent, dedups it within the instance, and pushes it into the missingEvents ring buffer.
  4. Every 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.

Why the gate matters

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
  • the specific (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.


Custom transport

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";

Realtime updates

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 / centrifugoTokenEndpoint config keys were removed in 0.9.0. Install @sonenta/realtime and use the plugin instead.


Offline / first-paint snapshot

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.

Generating the snapshot

  • CLI (recommended): verbumia snapshot (from @verbumia/cli) fetches the current published bundles and writes the JSON module.
  • Manual: fetch each https://cdn.sonenta.com/p/<project>/<version>/latest/<locale>/<ns>.json and assemble them into { [locale]: { [namespace]: <tree> } }, then import it.

Surface variants

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.

Accessibility surfaces

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 (cognitive) toggle: include 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).

Resolution by key type

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).
  • text types → 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.

Available languages (runtime switcher)

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>
  );
}
  • A newly-published language appears on the next load (the manifest is CDN-served), or immediately after 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.
  • Falls back to defaultLocale + fallbackLng when no manifest is available (or env: "dev", which is CDN-only). Opt out with disableLanguageManifest.
  • Also imperative: i18n.availableLanguages.

Recipes

Next.js (App Router)

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

// .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>
  ),
];

Cypress

cy.intercept("POST", "**/v1/missing", (req) => {
  cy.task("captureMissing", req.body);
  req.reply({ accepted: req.body.events.length, rejected: 0, items: [] });
});

Versioning

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.

License

MIT — see LICENSE.