DEV Community

Eray Gündoğmuş
Eray Gündoğmuş

Posted on

Stop Managing Translations in JSON Files — There's a Better Way

Every multilingual app starts the same way:

locales/
├── en.json    ← 12 keys
├── tr.json    ← 12 keys
└── de.json    ← 12 keys
Enter fullscreen mode Exit fullscreen mode

Six months later:

locales/
├── en.json    ← 847 keys
├── tr.json    ← 791 keys (56 missing, nobody knows which)
├── de.json    ← 823 keys (24 orphaned from a deleted feature)
├── ja.json    ← 402 keys (translator quit halfway)
└── ar.json    ← 0 keys (we said we'd get to it)
Enter fullscreen mode Exit fullscreen mode

If this looks familiar, keep reading.

The Real Problem

The issue isn't JSON files. JSON is fine. The problem is that translations live outside your development workflow.

Your code goes through pull requests, CI checks, type checking, and automated tests. Your translations? Someone exports a spreadsheet, a translator fills in cells, and someone else copies the values into JSON files. No review. No validation. No automated checks.

This means:

  • Keys drift — a developer renames save_draft to save_as_draft in code, but the translation files still have save_draft
  • Dead keys accumulate — features get deleted, translation keys don't
  • Format errors hide{count, plural, one {# item} other {# items}} is easy to break and hard to spot
  • Deploys block on translations — fixing a typo requires a full rebuild

What if translations worked like code?

That's what we built with Better i18n. Here's the workflow:

Developer adds t('pricing.title') in code
        ↓
CLI scans the codebase, detects the new key
        ↓
Key appears in the translation dashboard
        ↓
Translator (or AI) adds the translation
        ↓
Better i18n creates a PR in your GitHub repo
        ↓
Team reviews the PR → CI checks pass → merge
        ↓
CDN cache updates → users see the new translation
Enter fullscreen mode Exit fullscreen mode

No JSON file juggling. No manual exports. No "did we translate this?" Slack messages.

Let's Build It

I'll walk through setting up a Next.js app with Better i18n. The same principles apply to React and React Native — I'll cover those at the end.

Step 1: Install

npm install @better-i18n/next @better-i18n/cli
Enter fullscreen mode Exit fullscreen mode

Two packages:

Step 2: Middleware for Locale Routing

// middleware.ts
import { createI18nMiddleware } from '@better-i18n/next';

export default createI18nMiddleware({
  locales: ['en', 'tr', 'de', 'ja'],
  defaultLocale: 'en',
});

export const config = {
  matcher: ['/((?!api|_next|.*\\..*).*)'],
};
Enter fullscreen mode Exit fullscreen mode

This gives you:

  • /about → English (default)
  • /tr/about → Turkish
  • /de/about → German
  • Automatic detection from Accept-Language header
  • Locale persistence via cookies

Step 3: Use Translations in Your Components

import { useTranslations } from '@better-i18n/next';

export default function PricingPage() {
  const t = useTranslations('pricing');

  return (
    <main>
      <h1>{t('title')}</h1>
      <p>{t('subtitle')}</p>

      {plans.map(plan => (
        <div key={plan.id}>
          <h3>{t(`plans.${plan.id}.name`)}</h3>
          <p>
            {t('price_per_month', {
              amount: plan.price,
              currency: plan.currency
            })}
          </p>
          <ul>
            {plan.features.map(f => (
              <li key={f}>{t(`features.${f}`)}</li>
            ))}
          </ul>
        </div>
      ))}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

The translation file for this might look like:

{
  "pricing": {
    "title": "Simple, transparent pricing",
    "subtitle": "No hidden fees. Cancel anytime.",
    "price_per_month": "{amount, number, currency} / month",
    "plans": {
      "starter": { "name": "Starter" },
      "pro": { "name": "Pro" },
      "enterprise": { "name": "Enterprise" }
    },
    "features": {
      "unlimited_projects": "Unlimited projects",
      "github_sync": "GitHub sync",
      "ai_translation": "AI-powered translation",
      "cdn_delivery": "CDN delivery"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Better i18n supports ICU MessageFormat — the same standard used by FormatJS, next-intl, and use-intl. Plurals, dates, numbers, select statements — all handled.

Step 4: Scan Your Codebase

Here's where it gets interesting. Instead of manually tracking which keys exist:

npx @better-i18n/cli scan
Enter fullscreen mode Exit fullscreen mode

Output:

Scanning src/ for translation keys...

✓ Found 847 translation keys across 124 files

  New keys (not yet translated):
    + pricing.plans.enterprise.name
    + pricing.features.cdn_delivery

  Unused keys (not found in code):
    - settings.old_theme_toggle
    - onboarding.deprecated_step_3

  Missing translations:
    ⚠ tr: 12 keys missing
    ⚠ de: 3 keys missing
    ⚠ ja: 45 keys missing
Enter fullscreen mode Exit fullscreen mode

The CLI uses AST parsing, not regex — so it understands your code structure. It catches dynamic keys, template literals, and namespace patterns.

Step 5: Sync

npx @better-i18n/cli sync
Enter fullscreen mode Exit fullscreen mode

This pushes the scan results to the Better i18n dashboard where translators can work on missing translations. When they're done, a PR lands in your repo.

AI Translation That Understands Context

Most machine translation tools translate strings in isolation. Better i18n sends context to Google Gemini:

  • Namespace — "this string is in the pricing section"
  • Glossary — "we translate 'workspace' as 'çalışma alanı' in Turkish"
  • Nearby keys — "the strings around this one say..."
  • ICU structure — "this is a plural form, handle it correctly"

The difference is noticeable. Instead of:

// Generic MT
"Workspace" → "Çalışma Alanı" (correct but formal)
"{count} items selected" → "{count} öğe seçildi" (wrong plural form)
Enter fullscreen mode Exit fullscreen mode

You get:

// Context-aware
"Workspace" → "Çalışma alanı" (matches glossary casing)
"{count, plural, one {# öğe seçildi} other {# öğe seçildi}}" (correct ICU)
Enter fullscreen mode Exit fullscreen mode

Bonus: Let AI Agents Handle It

We built an MCP server so AI assistants can manage translations directly:

npm install @better-i18n/mcp
Enter fullscreen mode Exit fullscreen mode

Connect it to Claude, Cursor, or Windsurf, and you can:

You: "Add the key 'dashboard.welcome_back' with value
      'Welcome back, {name}!' and translate it to Turkish and German"

AI:  Creates the key, translates it, and the changes flow
     through your GitHub PR workflow.
Enter fullscreen mode Exit fullscreen mode

The Model Context Protocol is becoming the standard for connecting AI assistants to external tools. If you're not using it yet, it's worth exploring.

React Native / Expo

Mobile apps need offline support and OTA translation updates. The @better-i18n/expo package handles both:

import { BetterI18nExpoProvider, useTranslations } from '@better-i18n/expo';

export default function App() {
  return (
    <BetterI18nExpoProvider
      projectId="your-project-id"
      defaultLocale="en"
      fallbackMessages={require('./messages/en.json')}
    >
      <RootNavigator />
    </BetterI18nExpoProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

What you get:

  • Offline-first — translations cached locally, works without network
  • Background updates — new translations fetched silently, no app store update
  • Bundled fallback — ships with default translations so the app works on first launch
  • Automatic locale detection — reads device language settings

The Full Picture

Here's what the complete workflow looks like:

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│  Developer   │────▶│  GitHub Repo  │◀────│  Translator  │
│  (adds key)  │     │  (source of   │     │  (via Better  │
│              │     │   truth)      │     │   i18n UI)   │
└─────────────┘     └──────┬───────┘     └─────────────┘
                           │
                    ┌──────▼───────┐
                    │   CDN Edge    │
                    │  (Cloudflare) │
                    └──────┬───────┘
                           │
              ┌────────────┼────────────┐
              ▼            ▼            ▼
         ┌────────┐  ┌────────┐  ┌────────┐
         │ Next.js │  │ React  │  │  Expo  │
         │  App    │  │  App   │  │  App   │
         └────────┘  └────────┘  └────────┘
Enter fullscreen mode Exit fullscreen mode

No separate translation pipeline. No manual exports. No "which JSON file has the latest version?" conversations.

Try It

The SDKs are open source under MIT:

GitHub logo better-i18n / oss

Official TypeScript SDKs for Better i18n — Next.js, React, Expo/React Native integrations with CLI, MCP server, and CDN-powered translation delivery

Better i18n

TypeScript SDKs for the Better i18n localization platform

Manage translations with CDN delivery, GitHub sync, and AI-powered workflows
Documentation · Website · Blog

CLI version Next.js SDK version Expo SDK version MCP version MIT License


Why Better i18n?

Most localization tools weren't built for modern developer workflows. Better i18n is different:

  • GitHub-native — translations sync as PRs, reviews happen in your existing workflow
  • CDN-delivered — translations served from Cloudflare's edge network, updated without redeployment
  • AI-powered — context-aware translation with Google Gemini, not word-by-word machine output
  • TypeScript-first — full type safety across Next.js, React, Expo, and React Native
  • MCP-ready — AI agents can manage translations through the Model Context Protocol

How it compares



















































Feature Better i18n Crowdin Lokalise Phrase
GitHub-first workflow Partial Partial
AI translation with context
CDN delivery (no redeploy)
TypeScript SDKs
MCP server for AI agents
CLI scanner





Links:

If you have questions, drop a comment below or open an issue on GitHub.

Top comments (0)