react-translate-kit
v0.1.0
Published
Drop-in translation plugin for React
Downloads
7
Maintainers
Readme
react-translate-kit
A drop-in translation plugin for React, Next.js, and anything React-based.
Define your translator once in a module, import it anywhere — no provider
wrapping required — and get a t() hook plus a customizable
<LanguageToggle> component.
Installation
npm install react-translate-kitReact 17 or newer is required.
How translation works
The primary way to translate content is through a translation provider —
a connection to a real translation API (DeepL, Google, MyMemory,
LibreTranslate, or your own backend). Point createTranslator at a provider
and your copy is translated automatically as languages change, with no need
to hand-author every string in every language up front.
For parts of the app where you want exact, hand-written wording per language
— a hero headline, legal copy, brand voice — you can customize the output by
authoring an i18n key-based dictionary. Keys you define in translations
always win over the provider; the provider only fills in what you haven't
written yourself:
- dot-notation for nested keys (
"nav.home") {placeholder}interpolation (t("greeting", { name: "Rohan" }))- automatic fallback to
fallbackLanguagewhen a key is missing
Switching language changes which dictionary is read and re-renders subscribed components. Combine the two freely: let the provider handle the bulk of your UI copy, and override specific keys wherever precise wording matters.
Translation providers (the primary way to translate)
Configure a translate function and the translator calls a translation API
for any key that isn't explicitly authored in the active language's
dictionary:
import { createTranslator, myMemoryProvider } from "react-translate-kit";
export const i18n = createTranslator({
translations,
defaultLanguage: "en",
autoTranslate: { translate: myMemoryProvider() },
});How it behaves:
t()stays synchronous: it returns the fallback-language text immediately, fires the API request in the background, and re-renders subscribed components with the translated text when it arrives.- Results are cached in memory and localStorage
(
rtk-mt:<lang>:<key>:<hash of the source text>), so each cache key and source text is normally translated only once per browser. SetautoTranslate.cachePrefix: falsefor memory-only caching. - Editing your copy invalidates its cache. The key includes a fingerprint
of the source text, so shipping a reworded string re-translates it instead of
serving the translation of the wording you deleted. For keyed
t()translations, the superseded entry is removed when the new one is written. - Duplicate in-flight requests are deduped; API failures log a warning and leave the fallback text in place.
- Never runs during SSR — the client picks it up after hydration.
Built-in providers (all proper translation APIs)
| Provider | Service | Key needed |
| --- | --- | --- |
| myMemoryProvider() | MyMemory (translated.net) | No — free; pass { email } to raise the quota |
| deeplProvider({ apiKey }) | DeepL | Yes |
| googleProvider({ apiKey }) | Google Cloud Translation v2 | Yes |
| libreTranslateProvider({ url }) | LibreTranslate (self-hosted/open-source) | Instance-dependent |
import { deeplProvider } from "react-translate-kit";
createTranslator({
translations,
autoTranslate: {
// Your backend route adds the DeepL API key before forwarding the request.
translate: deeplProvider({ endpoint: "/api/translate" }),
},
});Security note: never ship a paid API key to the browser. The direct form
below is for server-only code. In browser code, use an endpoint override that
points to a backend route which adds the key server-side, or write your own
TranslateFn:
// Server-only code
deeplProvider({ apiKey: process.env.DEEPL_KEY! });import type { TranslateFn } from "react-translate-kit";
const viaMyBackend: TranslateFn = async (text, from, to) => {
const res = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, from, to }), // your server calls DeepL/Google
});
return (await res.json()).translation;
};
createTranslator({ translations, autoTranslate: { translate: viaMyBackend } });Caveat: machine translation runs on the raw template, so {placeholders}
usually survive but review high-stakes copy for anything customer-facing or
high-stakes — that's what the key-based dictionary below is for.
Zero-integration mode: <TranslateDOM>
Wrap your app once and every string inside it is translated — no t() calls,
no keys, no changes to existing components:
<TranslateDOM> can use dictionary translations by itself, but it needs an
autoTranslate provider to translate text that is not in the dictionary.
import { TranslateDOM } from "react-translate-kit";
import "./i18n";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<TranslateDOM>
<App />
</TranslateDOM>
</StrictMode>
);<h1>Welcome</h1> written as plain JSX becomes <h1>Bienvenido</h1> when the
language changes. Nothing else in your codebase has to know i18n exists.
Your dictionary still wins. <TranslateDOM> resolves the same way t()
does — customization first, API second. When a string it finds in the DOM
matches a value you authored in the source language, it traces it back to that
key and uses your translation for the active language, without calling the API
at all:
translations: {
en: { hero: { title: "Welcome" } },
fr: { hero: { title: "Bienvenue" } },
}<h1>Welcome</h1> // renders "Bienvenue" — your wording, no requestSo you can wrap everything in plain JSX and override any string later by adding a dictionary entry, without touching the component. Strings nobody authored fall through to the provider as usual. When two keys share the same source text, the first wins, so overriding it covers every occurrence.
The exception is interpolation: an entry reading "You have {count} messages"
can never match a rendered "You have 3 messages", and DOM translation would
request every value separately. Keep anything with a placeholder on t(),
where the template itself is translated once.
Why it works at the DOM layer. React gives a parent no access to a child's
rendered output — a wrapper receives <App /> as an unrendered element, so it
can't see the "Welcome" inside some component's <h1>. <TranslateDOM>
therefore runs one level lower: a MutationObserver over its subtree, walking
real text nodes and rewriting them in place. It re-sweeps on every re-render,
route change, language switch, and incoming API result, and it remembers each
node's source text separately from what it wrote, so switching back and forth
never round-trips machine translation through itself.
What it skips automatically: <script>, <style>, <noscript>, <code>, <pre>,
<kbd>, <samp>, <var>, <textarea>, <input>, <select>, <option>,
<svg>, plus anything marked data-no-translate, translate="no", or
.notranslate. Strings without letters (numbers, punctuation) never hit the
API. <LanguageToggle> opts itself out, so language names stay in their own
language.
<p data-no-translate>Stays in the source language.</p>
<TranslateDOM
skipTags={["FIGCAPTION"]}
skipSelector=".user-generated"
skip={(text) => text.startsWith("@")}
>
<App />
</TranslateDOM>Trade-offs. Translations land after a paint, so there's a brief flash of
the source language; it never runs during SSR; and it translates
indiscriminately until you opt regions out. For copy that must be exact, author
a dictionary key and use t() — dictionary entries always beat the provider.
Customizing with the i18n key-based dictionary
Provider translation covers the general case; dictionaries are how you pin down exact wording for specific keys. Anything you author here takes precedence over the provider, so use it wherever phrasing needs to be exact.
This matters more than it sounds: machine translation picks a sense without
context. "Welcome" came back from MyMemory as Accueil (French for a
reception desk) — correct-ish, wrong register for a hero headline. Pin it:
createTranslator({
translations: {
en: { hero: { title: "Welcome" } },
fr: { hero: { title: "Bienvenue" } }, // wins over the provider
},
autoTranslate: { translate: myMemoryProvider() },
});Quick start (dictionary only, no provider)
1. Create the translator once, in a module:
// src/i18n.ts
import { createTranslator } from "react-translate-kit";
export const i18n = createTranslator({
translations: {
en: { nav: { home: "Home" }, greeting: "Welcome back, {name}!" },
es: { nav: { home: "Inicio" }, greeting: "¡Bienvenido de nuevo, {name}!" },
},
defaultLanguage: "en",
languageLabels: { en: "English", es: "Español" },
});2. Use it from any page or component:
import { useTranslation, LanguageToggle } from "react-translate-kit";
import "../i18n"; // ensure the translator module is loaded
function Header() {
const { t } = useTranslation(); // finds the global translator automatically
return (
<header>
<a href="/">{t("nav.home")}</a>
<p>{t("greeting", { name: "Rohan" })}</p>
<LanguageToggle />
</header>
);
}The first createTranslator() call becomes the app-wide default that
useTranslation(), <LanguageToggle>, and <Trans> resolve to. State lives
outside React (hooks subscribe via useSyncExternalStore), so every page,
route, modal, or portal shares the same language with zero wrapping.
You can also use it outside React entirely — i18n.t("nav.home"),
i18n.setLanguage("es"), and i18n.subscribe(cb) work in plain JS (event
handlers, analytics, document.title, etc.).
Next.js
Works with both routers. The published bundle carries a "use client" banner,
so importing from server components in the App Router automatically treats it
as a client module.
// app/lib/i18n.ts
import { createTranslator } from "react-translate-kit";
export const i18n = createTranslator({ ... });
// app/components/Header.tsx
"use client";
import { useTranslation, LanguageToggle } from "react-translate-kit";
import "@/lib/i18n";
export function Header() {
const { t } = useTranslation();
return <header>{t("nav.home")} <LanguageToggle variant="select" /></header>;
}Language state is client-side (it depends on localStorage / navigator), so use
it from client components. On the server it renders defaultLanguage and
hydrates to the user's stored choice on the client.
Scoping a translator
Everything resolves to the app-wide translator from createTranslator() by
default. To run a second one — an embedded widget with its own languages, a
preview pane, a test — pass it explicitly; every entry point takes a
translator argument:
const widgetI18n = createTranslator({
translations: widgetCopy,
persistKey: false, // don't fight the host app over stored language
detectBrowserLanguage: false,
});
const { t } = useTranslation(widgetI18n);
<TranslateDOM translator={widgetI18n}><Widget /></TranslateDOM>
<LanguageToggle translator={widgetI18n} />
<Trans i18nKey="title" translator={widgetI18n} />Resolution order: explicit translator argument → app-wide default.
Customizing the toggle
Three built-in variants:
<LanguageToggle variant="buttons" /> {/* pill per language (default) */}
<LanguageToggle variant="switch" /> {/* one button that cycles — great for 2 languages */}
<LanguageToggle variant="select" /> {/* native dropdown — great for many languages */}Style it:
<LanguageToggle
className="my-toggle"
activeStyle={{ background: "#4f46e5", color: "#fff" }}
renderLabel={(code, isActive) => <Flag code={code} dimmed={!isActive} />}
languages={["en", "es"]} // restrict/order which languages appear
/>Or replace the UI entirely with a render prop:
<LanguageToggle>
{({ language, languages, setLanguage, getLabel }) => (
<MyDropdown
value={language}
options={languages.map((l) => ({ value: l, label: getLabel(l) }))}
onChange={setLanguage}
/>
)}
</LanguageToggle>Config options (createTranslator)
| Option | Default | Description |
| --- | --- | --- |
| translations | (required) | { [lang]: { key: "text", nested: { key: "text" } } } |
| defaultLanguage | first language | Used when nothing is stored/detected; also the SSR language |
| fallbackLanguage | defaultLanguage | Used when a key is missing in the active language |
| persistKey | "rtk-language" | localStorage key; pass false to disable persistence |
| detectBrowserLanguage | true | Match navigator.languages on first visit |
| languageLabels | — | Display names, e.g. { en: "English" } |
| onLanguageChange | — | Callback fired on every switch |
| autoTranslate | — | { translate, cachePrefix? } — API fallback for missing keys |
API surface
createTranslator(config)→Translator(first one becomes the app-wide default)Translator:t(),translateText(),language,languages,setLanguage(),toggleLanguage(),getLabel(),subscribe(),addTranslations()useTranslation(translator?)→{ t, language, languages, setLanguage, toggleLanguage, getLabel }<TranslateDOM>— translate a whole subtree, not()calls required<Trans i18nKey="..." />— the JSX form oft()<LanguageToggle>— three variants plus a render propsetDefaultTranslator(t)/getDefaultTranslator()
Every component and hook accepts an optional translator to scope it.
Choosing between them
| | Keys? | Scope | Use for |
| --- | --- | --- | --- |
| <TranslateDOM> | No | Whole subtree | Static copy — the default. Zero refactor. |
| t() / <Trans> | Yes | One string | Interpolation, and anything that must be exact |
Both resolve the same way — an authored dictionary entry wins, the API is only consulted for text nobody customized — and share one cache and one language state.
Reach for t() whenever a string has a {placeholder}: the template is
translated once, whereas DOM translation would treat every rendered value as a
new string and request each one separately.
Development
npm install && (cd demo && npm install)
npm run dev # demo at http://localhost:5173 — two pages, no provider
npm run build # publishable package in dist/ (ESM + CJS + types)src/ the library demo/ Vite demo app dist/ build output