# i18n Without Translation Keys: Meet the Babelize SDK

> Wrap the English text where it already is, and a build plugin translates it into a lockfile your app reads at runtime. How the Babelize SDK works in React, Next.js and React Native, and its trade-offs.
- **Author**: Mohit Sapat
- **Published**: 2026-10-27
- **URL**: https://www.babelize.co/blogs/i18n-without-translation-keys-babelize-sdk

---

<p class="lead">
    Most i18n setups start the same way: invent a key like <code>home.hero.title</code>, move the English text into <code>en.json</code>, replace it with <code>t("home.hero.title")</code>, and repeat for every string in the app. The Babelize SDK skips that step. You wrap the English text where it already is, and a build plugin finds it, translates it and writes a lockfile your app reads at runtime. This post shows how it works in React, Next.js and React Native, and where the trade-offs are.
</p>

<h2>Quick Answer</h2>

<p>
    Install <code>@babelize/sdk</code> and the build plugin for your bundler (<code>@babelize/next</code>, <code>@babelize/vite</code> or <code>@babelize/metro</code>). Wrap text with <code>babelize("Welcome back")</code>, or <code>useBabelize()</code> in React components. On build, the plugin scans for those calls, translates every string into your target locales through the Babelize API, and writes <code>babelize-lock.json</code>. The app loads the lockfile and reads translations from it, so production makes no translation API calls and your API key never reaches the browser.
</p>

<h2>Keys vs Source Strings</h2>

<p>
    There are two ways to identify a message in i18n. Key-based libraries such as react-i18next and next-intl use an ID you choose:
</p>

<pre><code>// en.json
{ "home": { "welcome": "Welcome back, {{name}}" } }

// Home.tsx
&lt;h1&gt;{t("home.welcome", { name })}&lt;/h1&gt;</code></pre>

<p>
    Source-string libraries use the English text itself as the ID. GNU gettext has worked this way for decades (Django's <code>gettext("Welcome back")</code> is an example), and Lingui's macros do it in React. The Babelize SDK takes the same approach:
</p>

<pre><code>// Home.tsx
&lt;h1&gt;{babelize("Welcome back, {name}", { name })}&lt;/h1&gt;</code></pre>

<p>
    The benefit is that the component stays readable and there is no second file to keep in sync while you build. The cost is that changing the English text changes the ID, so the new text needs translating again. For a product team that edits copy often, that is usually what you want anyway; for a large app with professional translators, keys give more control. We cover the key-based side in <a href="/blogs/json-translation-files-explained/">JSON translation files explained</a>.
</p>

<h2>How the SDK Works</h2>

<ol>
    <li><strong>You wrap strings.</strong> <code>babelize("...")</code>, <code>babelize.p(...)</code> for plurals, or the <code>&lt;BabelizeTag&gt;</code> component for sentences with markup.</li>
    <li><strong>The build plugin scans.</strong> It parses your source files and collects every wrapped string and plural template.</li>
    <li><strong>It translates.</strong> Each string is translated into the locales you configured, using your <code>BABELIZE_API_KEY</code> at build time only.</li>
    <li><strong>It writes a lockfile.</strong> <code>babelize-lock.json</code> maps each source string to its translations. Commit it.</li>
    <li><strong>The app reads the lockfile.</strong> At runtime, a lookup is an in-memory read. A string missing from the lockfile is shown in English instead of breaking the page.</li>
</ol>

<p>The lockfile is plain JSON:</p>

<pre><code>{
  "version": 1,
  "strings": {
    "Welcome back, {name}": {
      "fr": "Bon retour, {name}",
      "de": "Willkommen zurück, {name}",
      "ja": "おかえりなさい、{name}さん"
    }
  }
}</code></pre>

<h2>React with Vite</h2>

<p>Install the SDK and the Vite plugin:</p>

<pre><code>npm install @babelize/sdk
npm install -D @babelize/vite</code></pre>

<p>Add the plugin with the languages you want:</p>

<pre><code>// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import babelize from "@babelize/vite";

export default defineConfig({
  plugins: [react(), babelize({ locales: ["fr", "de", "ja"] })],
});</code></pre>

<p>
    Put <code>BABELIZE_API_KEY</code> in <code>.env</code>, without a <code>VITE_</code> prefix: the plugin reads it at build time, and a <code>VITE_</code> variable would be bundled into the browser code. Then load the lockfile and wrap your app:
</p>

<pre><code>// src/main.tsx
import { createRoot } from "react-dom/client";
import { babelize } from "@babelize/sdk";
import { BabelizeProvider } from "@babelize/sdk/react";
import lockfile from "virtual:babelize-lockfile";
import { App } from "./App";

lockfile &amp;&amp; babelize.loadLockfile(lockfile);
babelize.init(); // no API key in browser code

createRoot(document.getElementById("root")!).render(
  &lt;BabelizeProvider&gt;
    &lt;App /&gt;
  &lt;/BabelizeProvider&gt;,
);</code></pre>

<p>TypeScript doesn't know the virtual module, so declare it once:</p>

<pre><code>// src/babelize.d.ts
declare module "virtual:babelize-lockfile" {
  import type { LockfileData } from "@babelize/sdk";
  const lockfile: LockfileData | null;
  export default lockfile;
}</code></pre>

<p>In components, use the hook. Name its result <code>babelize</code>: the build plugin finds strings by looking for <code>babelize("...")</code> calls, so a hook result called <code>t</code> would be skipped.</p>

<pre><code>import { useBabelize, useLocale } from "@babelize/sdk/react";

export function Header({ name, unread }: { name: string; unread: number }) {
  const babelize = useBabelize();
  const { locale, setLocale } = useLocale();

  return (
    &lt;header&gt;
      &lt;h1&gt;{babelize("Welcome back, {name}", { name })}&lt;/h1&gt;
      &lt;p&gt;{babelize.p("{count} new message", "{count} new messages", unread)}&lt;/p&gt;
      &lt;select value={locale} onChange={(e) =&gt; setLocale(e.target.value)}&gt;
        &lt;option value="en"&gt;English&lt;/option&gt;
        &lt;option value="fr"&gt;Français&lt;/option&gt;
        &lt;option value="ja"&gt;日本語&lt;/option&gt;
      &lt;/select&gt;
    &lt;/header&gt;
  );
}</code></pre>

<p>
    <code>setLocale</code> re-renders every component that uses the hook. Without it, the SDK picks the browser's language and falls back to English.
</p>

<h2>Next.js App Router</h2>

<p>Wrap your Next.js config:</p>

<pre><code>// next.config.ts
import withBabelize from "@babelize/next";

export default withBabelize({}, { locales: ["fr", "de", "ja"] });</code></pre>

<p>
    The plugin runs while Next.js loads the config, before Turbopack or webpack starts, so the lockfile is ready before the bundle is built. It runs on <code>next build</code> and once when <code>next dev</code> starts. If any locale fails to translate, the build fails rather than shipping a partial lockfile.
</p>

<p>For Server Components, add the middleware that picks the locale from a cookie or the <code>Accept-Language</code> header:</p>

<pre><code>// proxy.ts (middleware.ts before Next.js 16)
import { babelizeMiddleware } from "@babelize/sdk/next";

export default babelizeMiddleware({ supportedLocales: ["en", "fr", "de", "ja"] });</code></pre>

<p>Then translate on the server:</p>

<pre><code>// app/page.tsx
import { babelizeRSC, loadLockfile } from "@babelize/sdk/next";
import lockfile from "../babelize-lock.json";

loadLockfile(lockfile);

export default async function Home() {
  return &lt;h1&gt;{await babelizeRSC("Welcome to our store")}&lt;/h1&gt;;
}</code></pre>

<p>
    Client Components use the same <code>useBabelize()</code> hook as in the Vite example. The <a href="https://docs.babelize.co/sdk/nextjs" target="_blank" rel="noopener noreferrer">Next.js SDK docs</a> cover the full setup.
</p>

<h2>React Native and Expo</h2>

<p>The Metro plugin writes the lockfile before Metro bundles, for <code>expo start</code>, <code>expo export</code> and the React Native CLI:</p>

<pre><code>// metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const { withBabelize } = require("@babelize/metro");

module.exports = withBabelize(getDefaultConfig(__dirname), {
  locales: ["fr", "de", "ja"],
});</code></pre>

<pre><code>// App.tsx
import { getLocales } from "expo-localization";
import { babelize } from "@babelize/sdk";
import lockfile from "./babelize-lock.json";

babelize.loadLockfile(lockfile);
babelize.init({ detectLocale: () =&gt; getLocales()[0]?.languageCode ?? null });</code></pre>

<p>
    Render translations inside <code>&lt;Text&gt;</code> as usual. Never pass an API key in a mobile app; anything in the bundle can be extracted. The <a href="https://docs.babelize.co/sdk/react-native" target="_blank" rel="noopener noreferrer">React Native SDK docs</a> have the details.
</p>

<h2>Plurals, ICU and Rich Text</h2>

<ul>
    <li><strong><code>babelize.p(singular, plural, count)</code></strong> takes the two English forms and returns the right form for the current language using <code>Intl.PluralRules</code>, including the extra forms in Polish, Russian or Arabic.</li>
    <li><strong><code>babelize.plural({ one, few, many, other }, { count })</code></strong> gives full control over all six CLDR plural forms.</li>
    <li><strong><code>babelize.icu("{count, plural, one {# item} other {# items}}", { count })</code></strong> accepts ICU MessageFormat and translates the text inside it while keeping the structure. See our <a href="/blogs/icu-messageformat-plurals-guide/">ICU MessageFormat guide</a> for the syntax.</li>
    <li><strong><code>&lt;BabelizeTag&gt;</code></strong> translates a sentence that contains markup as one unit, so the translator can reorder it for languages like Japanese:</li>
</ul>

<pre><code>&lt;BabelizeTag
  template="Read our {terms} before you {sign up}"
  segments={{
    terms: &lt;a href="/terms"&gt;terms&lt;/a&gt;,
    "sign up": &lt;strong&gt;sign up&lt;/strong&gt;,
  }}
/&gt;</code></pre>

<h2>Trade-offs to Know</h2>

<p>The SDK is new (version 0.1), so here is where it fits and where it doesn't yet:</p>

<ul>
    <li><strong>Same text, different meaning.</strong> Because the English text is the ID, "Close" on a button and "Close" meaning "nearby" share one translation. Key-based libraries, or gettext's contexts, handle that better.</li>
    <li><strong>Editing copy means retranslating.</strong> Changing "Sign up" to "Create account" is a new string. The next build translates it, which uses credits.</li>
    <li><strong>Vite writes the lockfile one build late.</strong> The Vite plugin writes the lockfile after the bundle is emitted, so a fresh checkout needs a second build, and new strings appear one build later. Committing <code>babelize-lock.json</code> avoids surprises. The Next.js and Metro plugins write it before bundling and don't have this issue.</li>
    <li><strong>Scanning is static.</strong> The plugins find literal strings in <code>babelize("...")</code> calls. A string built at runtime, like <code>babelize(`Status: ${code}`)</code>, can't be found, and neither can a call under another name unless you set the plugin's <code>functionName</code> option.</li>
    <li><strong>No translator workspace.</strong> Translations come from the Babelize API. If you work with human translators and a TMS, a key-based library with locale files is the better fit.</li>
</ul>

<h2>SDK or CLI?</h2>

<p>
    Babelize also has a CLI that takes the opposite approach: <code>npx babelize translate</code> finds hardcoded strings, wraps them in react-i18next, next-intl or Django gettext calls, and writes standard locale files to your repo. The two suit different projects:
</p>

<table>
    <thead>
        <tr>
            <th></th>
            <th>SDK</th>
            <th>CLI</th>
        </tr>
    </thead>
    <tbody>
        <tr><td>What you write</td><td><code>babelize("Welcome back")</code></td><td>Nothing; it rewrites to <code>t("home.welcomeBack")</code></td></tr>
        <tr><td>Translations live in</td><td><code>babelize-lock.json</code></td><td>Standard JSON or <code>.po</code> locale files</td></tr>
        <tr><td>Runtime library</td><td><code>@babelize/sdk</code></td><td>react-i18next, next-intl or gettext</td></tr>
        <tr><td>Translates</td><td>On every build</td><td>When you run it, or in a pull request</td></tr>
        <tr><td>Best for</td><td>New apps, prototypes, small teams</td><td>Existing apps, teams that want standard libraries</td></tr>
    </tbody>
</table>

<p>
    If you would rather pick a standard library, our <a href="/blogs/best-i18n-libraries-2027/">best i18n libraries for 2027</a> post picks one per framework, and the CLI can do the first wrapping pass for it.
</p>
---
- [All articles](https://www.babelize.co/blogs)