Setting up Next.js i18n routing is the easy part. The pain starts when you have a messages.json with 500 keys, a product team that adds ten new strings every sprint, and ten target languages that all need updating — by hand. This guide sets up locale routing in the App Router, then builds a script that automatically translates your message catalog through a translation API, wires it into CI/CD, and adds a sane review step so machine output never ships blindly. The result: adding a string in English is the only manual work you ever do.

Setting Up i18n Routing in the App Router

The App Router does not ship a built-in i18n config the way the old Pages Router did — you drive locale routing with a dynamic [lang] segment and a small middleware that redirects users to their locale.

// middleware.ts — redirect "/" to the user's locale
import { NextRequest, NextResponse } from "next/server";

const locales = ["en", "es", "fr", "de", "ja"];
const defaultLocale = "en";

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;
  const hasLocale = locales.some(
    (l) => pathname.startsWith("/" + l + "/") || pathname === "/" + l
  );
  if (hasLocale) return;

  const accept = req.headers.get("accept-language") || "";
  const preferred = accept.split(",")[0].split("-")[0];
  const locale = locales.includes(preferred) ? preferred : defaultLocale;

  req.nextUrl.pathname = "/" + locale + pathname;
  return NextResponse.redirect(req.nextUrl);
}

export const config = { matcher: ["/((?!_next|api|.*\\..*).*)"] };

Your pages live under app/[lang]/, and a small helper loads the right message file per request:

// app/[lang]/dictionaries.ts
import "server-only";

const dictionaries = {
  en: () => import("@/messages/en.json").then((m) => m.default),
  es: () => import("@/messages/es.json").then((m) => m.default),
  fr: () => import("@/messages/fr.json").then((m) => m.default),
  de: () => import("@/messages/de.json").then((m) => m.default),
  ja: () => import("@/messages/ja.json").then((m) => m.default),
};

export const getDictionary = (lang) =>
  (dictionaries[lang] ?? dictionaries.en)();
// app/[lang]/page.tsx
import { getDictionary } from "./dictionaries";

export default async function Home({ params }) {
  const t = await getDictionary(params.lang);
  return <h1>{t.home.title}</h1>;
}

The Problem with Manual JSON Locale Files

You now have en.json as the source of truth and one file per language. The problem is keeping the other files in sync. Every time a developer adds en.json"checkout.button": "Place order", someone has to:

  • Notice the new key exists.
  • Add it to all nine other locale files.
  • Get each value translated.
  • Preserve the exact nested key structure and any {placeholders}.

Done by hand, this drifts within a single sprint. Missing keys throw at runtime or render raw key names to users. The fix is to treat en.json as the only file humans edit and generate the rest.

Automating Translation of messages.json

Here is a complete script that reads en.json, finds keys missing from each target locale, translates only those, and writes updated files — preserving the nested structure and leaving existing (possibly human-reviewed) translations untouched.

// scripts/translate-messages.mjs
import { readFile, writeFile } from "node:fs/promises";

const SOURCE = "en";
const TARGETS = ["es", "fr", "de", "ja", "pt", "it", "ko", "zh", "ar", "nl"];
const DIR = "./messages";
const API = "https://aibit-translator.p.rapidapi.com/api/v1/translator/json";

// Collect the subtree of keys present in source but missing in target.
function missingSubtree(src, dst) {
  const out = Array.isArray(src) ? [] : {};
  let found = false;
  for (const key of Object.keys(src)) {
    const s = src[key];
    const d = dst?.[key];
    if (s && typeof s === "object") {
      const child = missingSubtree(s, d ?? (Array.isArray(s) ? [] : {}));
      if (child !== null) { out[key] = child; found = true; }
    } else if (d === undefined) {
      out[key] = s; found = true;
    }
  }
  return found ? out : null;
}

// Deep-merge translated values back into the existing target file.
function deepMerge(base, patch) {
  const out = Array.isArray(base) ? [...base] : { ...base };
  for (const key of Object.keys(patch)) {
    out[key] =
      patch[key] && typeof patch[key] === "object"
        ? deepMerge(base?.[key] ?? (Array.isArray(patch[key]) ? [] : {}), patch[key])
        : patch[key];
  }
  return out;
}

async function translateJson(json, to) {
  const res = await fetch(API, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-RapidAPI-Key": process.env.RAPIDAPI_KEY,
    },
    body: JSON.stringify({ from: SOURCE, to, json }),
  });
  if (!res.ok) throw new Error(to + ": HTTP " + res.status);
  const data = await res.json();
  return data.trans ?? data.json ?? data;
}

const source = JSON.parse(await readFile(DIR + "/" + SOURCE + ".json", "utf8"));

for (const lang of TARGETS) {
  let existing = {};
  try {
    existing = JSON.parse(await readFile(DIR + "/" + lang + ".json", "utf8"));
  } catch { /* first run: no file yet */ }

  const todo = missingSubtree(source, existing);
  if (!todo) {
    console.log(lang + ": up to date");
    continue;
  }

  const translated = await translateJson(todo, lang);
  const merged = deepMerge(existing, translated);
  await writeFile(DIR + "/" + lang + ".json", JSON.stringify(merged, null, 2) + "\n");
  console.log(lang + ": translated " + Object.keys(todo).length + " new key group(s)");
}

Two design choices make this production-safe. First, it only translates missing keys, so it is cheap to run repeatedly and never overwrites a human-corrected string. Second, it uses a JSON-native endpoint — you send the object and get the same shape back, so nested keys and {placeholders} survive without any manual masking. Sending the whole subtree in one call also beats one-request-per-string on both latency and cost.

CI/CD Integration

Run the script automatically whenever en.json changes so translations are never forgotten. A GitHub Actions workflow that opens a pull request with the regenerated files:

// .github/workflows/i18n.yml
name: Translate messages
on:
  push:
    paths: ["messages/en.json"]
jobs:
  translate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: node scripts/translate-messages.mjs
        env:
          RAPIDAPI_KEY: ${{ secrets.RAPIDAPI_KEY }}
      - uses: peter-evans/create-pull-request@v6
        with:
          commit-message: "chore(i18n): auto-translate new strings"
          title: "Auto-translate new i18n strings"
          branch: i18n/auto-translate

Now the flow is: a developer merges a new English string, CI translates it into all ten languages, and a PR appears for review. No one has to remember anything.

A Review Workflow for Machine Translations

Machine translation is excellent but not infallible — brand terms, tone, and edge cases still need eyes. The trick is to review without blocking releases. Two practical patterns:

  • Mark strings as machine-translated. Keep a parallel <lang>.status.json or a _mt flag so you know which values are unreviewed. A native speaker clears the flag as they verify, and the flag never re-sets because the script skips existing keys.
  • Protect terms with a glossary. Maintain a do-not-translate list (brand names, product terms) and mask them before sending, or pass them to an engine that supports glossaries. This kills the most common class of embarrassing errors.

Because the auto-translate PR is a normal pull request, your existing review process already applies: a reviewer who speaks the language can correct a value inline, merge, and that corrected string is now permanent — the next script run leaves it alone.

Cost for 500 Strings × 10 Languages

Assume 500 strings averaging 40 characters = 20,000 characters of source. Translating into 10 languages = 200,000 characters total for a full rebuild.

ProviderRate / 1M charsFull retranslate (200K chars)Typical sprint (20 new strings ×10)
Google Translate$20~$4.00~$0.16
DeepL~$25~$5.00~$0.20
AIbit Translator~$0.03~$0.006< $0.001

Two things stand out. First, because the script only translates new keys, your real ongoing cost is the tiny "typical sprint" column, not the full rebuild. Second, at this volume the absolute numbers are small everywhere — but the per-character rate still decides whether localizing a large app is a line item or a rounding error, and it compounds as you add languages and content.

Get Started

A fully automated Next.js localization pipeline needs one thing at its core: a translation API that speaks JSON natively, preserves your key structure and placeholders, and costs little enough to run on every push. AIbit Translator does exactly that — a JSON endpoint that returns the same shape you send, 240+ languages, multi-engine quality, and an effective ~$0.03 per million characters with a free tier. Drop the script above into your repo, add your key, and never hand-edit a locale file again. Start at aibitranslator.com.