DEV Community

shipi18n
shipi18n

Posted on • Originally published at shipi18n.com

Stop Shipping Broken Strings: Placeholder-Safe i18n in Node.js + Express

Stop Shipping Broken Strings: Placeholder-Safe i18n in Node.js + Express

One missing {{name}} can crash an email template, break a UI render, or—worse—silently corrupt analytics payloads. The scary part is that placeholder bugs often don’t show up in the language you test (usually English). They wait until a specific locale hits production and then fail in the most inconvenient place: a password reset email, a checkout confirmation, a PDF invoice, or an event pipeline you only notice weeks later.

This is the classic localization trap: placeholder drift. It’s a “looks fine in review” bug because the translation change is just a string diff… until runtime tries to interpolate variables that no longer match.

This article shows how to build an Express i18n pipeline that:

  1. Validates placeholders automatically (so missing variables and typos are caught immediately),
  2. Fails fast in CI (so broken locale files never ship),
  3. Degrades safely in production (so you don’t take down critical flows when something slips through).

Let’s define the problem precisely, then implement the guardrails.


1) The real problem: how placeholder drift breaks apps (and why it’s hard to catch in review)

1.1 What placeholder drift looks like in the wild

Placeholder drift happens when translation strings across locales stop matching the placeholder contract you intended.

Common mismatch types include:

  • Missing placeholders: base has {{name}}, another locale forgot it.
  • Renamed placeholders: {{orderId}} becomes {{orderID}} (case changes are brutal).
  • Extra placeholders: a translator adds {{company}} because it “reads better”.
  • Changed nesting: {{user.name}} vs {{name}}.
  • Plural/select inconsistencies: one plural branch forgets {{count}}, so it only breaks for certain values.

Where does it show up? Anywhere strings are templates:

  • Transactional emails (subject, preheader, body blocks)
  • Notifications and UI microcopy
  • Logs and analytics events (especially labels/properties)
  • PDF generation, invoices, receipts

And the impact is high:

  • Runtime exceptions (templating engines can throw)
  • Broken rendering (raw {{name}} leaks to users)
  • Wrong attribution (analytics labels missing identifiers)
  • Partial output (some libs replace unknown vars with empty strings)

Here’s a minimal example. Imagine you store translations as JSON and use a simple mustache-like renderer:

locales/en.json

{
  "email": {
    "reset": {
      "subject": "Reset your password, {{name}}"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

locales/fr.json

{
  "email": {
    "reset": {
      "subject": "Réinitialisez votre mot de passe"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If your email code assumes {{name}} is present, you might send:

  • English: “Reset your password, Sam”
  • French: “Réinitialisez votre mot de passe” (missing personalization)

Or, depending on your templating pipeline, you might crash if the template engine expects a variable that isn’t there (or if you validate inputs but only for English).

The key point: this isn’t a “translation quality” issue—it’s an API contract issue.

1.2 Why review doesn’t catch it (and why tests often miss it)

Placeholder drift is uniquely sneaky because localization workflows live outside your normal type system and compilation pipeline.

  • Translators often edit strings in a TMS, CSV export, or JSON file. Placeholder edits don’t trigger compile errors.
  • Reviewers don’t speak every language, and diffs are noisy. It’s easy to miss {{orderId}}{{orderID}} buried in a paragraph.
  • Unit tests commonly cover only one locale (usually en) and only a subset of keys.
  • Dynamic keys make static analysis harder: t(\email.${type}.subject) can’t easily be enumerated by a linter.

A realistic PR diff might look like this:

- "subject": "Votre commande {{orderId}} est confirmée"
+ "subject": "Votre commande {{orderID}} est confirmée"
Enter fullscreen mode Exit fullscreen mode

That’s a one-character change that can break every French confirmation email.

So the goal isn’t “review harder.” The goal is make placeholder correctness automatic: safe translation calls + validation across locales.

1.3 Define the contract: placeholders are part of the API

Treat each translation string like a function signature:

  • Key: email.reset.subject
  • Parameters: { name: string }
  • Return: localized string

If you change the placeholders, you changed the signature. That’s an API change.

A practical contract policy looks like this:

  • Base locale is canonical (often en): its placeholder set defines the required variables.
  • Every other locale must match the placeholder set for the same key.
  • Strictness varies by environment:
    • CI/dev: fail fast (throw)
    • Production: degrade safely (fallback to base locale or sentinel placeholders), but always log/alert

Now let’s implement that in Node.js + Express.


2) Implementing placeholder-safe translation calls in Node.js/Express

To keep examples self-contained, we’ll use JSON locale files and a simple translation function. You can adapt the same approach to i18next, node-polyglot, @formatjs/intl, etc.

2.1 Choose a template format and extraction strategy (Mustache/Handlebars/ICU)

If your placeholders look like {{name}}, extraction is straightforward and fast. If you use ICU MessageFormat (recommended for plural/select), you should parse with an ICU parser rather than regex.

For this article we’ll implement Mustache-style extraction, and later discuss ICU.

extractPlaceholders() (TypeScript, but works in JS too):

// i18n/placeholders.ts
export function extractPlaceholders(template: string): Set<string> {
  // Matches {{ name }}, {{user.name}}, but not {{{triple}}} specifically.
  // Adjust to your template syntax.
  const re = /{{\s*([a-zA-Z0-9_.-]+)\s*}}/g;
  const out = new Set<string>();
  let match: RegExpExecArray | null;

  while ((match = re.exec(template)) !== null) {
    out.add(match[1]);
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

This gives you a placeholder set you can compare to provided variables.

Your next move: wrap translation calls so validation is the default.

2.2 Build a tSafe() helper that validates placeholders at runtime (dev/test)

We’ll create:

  • a basic t() that resolves the localized string for a key
  • a tSafe() wrapper that:
    • extracts placeholders from the resolved template
    • checks missing variables
    • optionally checks extra variables (great for catching typos)
    • throws in strict mode, otherwise falls back safely

First, a tiny translator implementation:

// i18n/translator.ts
export type LocaleDict = Record<string, any>;

function getByPath(obj: any, key: string): unknown {
  return key.split(".").reduce((acc, part) => (acc ? acc[part] : undefined), obj);
}

export function createTranslator(dict: LocaleDict) {
  return function t(key: string): string {
    const value = getByPath(dict, key);
    if (typeof value !== "string") return key; // simple fallback
    return value;
  };
}
Enter fullscreen mode Exit fullscreen mode

Now the safe wrapper:

// i18n/tSafe.ts
import { extractPlaceholders } from "./placeholders.js";

type OnErrorMode = "throw" | "fallbackToBase" | "sentinel";

export type SafeOptions = {
  strict?: boolean;              // fail fast
  checkExtraVars?: boolean;      // catch typos
  onError?: OnErrorMode;         // production behavior
  baseT?: (key: string) => string; // fallback translator
  logger?: (info: Record<string, any>) => void;
};

function renderMustache(template: string, vars: Record<string, any>): string {
  return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, name) => {
    const v = vars[name];
    return v === undefined || v === null ? "" : String(v);
  });
}

export function createSafeTranslator(
  t: (key: string) => string,
  locale: string,
  opts: SafeOptions
) {
  const strict = opts.strict ?? false;
  const checkExtra = opts.checkExtraVars ?? true;
  const onError: OnErrorMode = opts.onError ?? (strict ? "throw" : "fallbackToBase");

  return function tSafe(key: string, vars: Record<string, any> = {}): string {
    const template = t(key);
    const placeholders = extractPlaceholders(template);

    const provided = new Set(Object.keys(vars));
    const missing = [...placeholders].filter((p) => !provided.has(p));
    const extra = checkExtra ? [...provided].filter((p) => !placeholders.has(p)) : [];

    if (missing.length === 0 && extra.length === 0) {
      return renderMustache(template, vars);
    }

    const errorInfo = {
      type: "i18n_placeholder_mismatch",
      key,
      locale,
      missing,
      extra,
      templateSnippet: template.slice(0, 120)
    };

    opts.logger?.(errorInfo);

    if (onError === "throw") {
      const msg =
        `i18n placeholder mismatch for "${key}" (${locale}). ` +
        (missing.length ? `Missing: ${missing.join(", ")}. ` : "") +
        (extra.length ? `Extra: ${extra.join(", ")}. ` : "") +
        `Template: "${template.slice(0, 80)}..."`;
      throw new Error(msg);
    }

    if (onError === "sentinel") {
      // Replace missing placeholders with visible markers
      const sentinelVars = { ...vars };
      for (const p of missing) sentinelVars[p] = `[missing:${p}]`;
      return renderMustache(template, sentinelVars);
    }

    // fallbackToBase
    if (opts.baseT) {
      const baseTemplate = opts.baseT(key);
      const basePlaceholders = extractPlaceholders(baseTemplate);
      const baseMissing = [...basePlaceholders].filter((p) => !(p in vars));

      if (baseMissing.length === 0) return renderMustache(baseTemplate, vars);

      // If even base can't render, last-resort sentinel
      const sentinelVars = { ...vars };
      for (const p of baseMissing) sentinelVars[p] = `[missing:${p}]`;
      return renderMustache(baseTemplate, sentinelVars);
    }

    // No base translator provided: return key as last resort
    return key;
  };
}
Enter fullscreen mode Exit fullscreen mode

This gives you a single translation API that enforces the contract whenever you want. In CI, set strict: true. In production, use fallbackToBase + logging.

Next: wire it into Express so every request gets the correct locale and a safe translator.

2.3 Express middleware: locale detection + request-scoped translator

You can detect locale from Accept-Language, a user profile, a cookie, or a URL prefix. Here’s a simple Accept-Language detector with a supported-locale list.

// i18n/middleware.ts
import type { Request, Response, NextFunction } from "express";
import { createTranslator } from "./translator.js";
import { createSafeTranslator } from "./tSafe.js";

type MiddlewareConfig = {
  dictionaries: Record<string, any>; // { en: {...}, fr: {...} }
  baseLocale: string;
  supportedLocales: string[];
  strict: boolean;
  logger?: (info: Record<string, any>) => void;
};

function pickLocale(acceptLanguage: string | undefined, supported: string[], base: string) {
  if (!acceptLanguage) return base;
  // Extremely simplified parsing: "fr-CA,fr;q=0.9,en;q=0.8"
  const candidates = acceptLanguage.split(",").map((s) => s.trim().split(";")[0]);
  for (const c of candidates) {
    const normalized = c.toLowerCase();
    const exact = supported.find((l) => l.toLowerCase() === normalized);
    if (exact) return exact;

    // match "fr-CA" -> "fr"
    const lang = normalized.split("-")[0];
    const baseMatch = supported.find((l) => l.toLowerCase() === lang);
    if (baseMatch) return baseMatch;
  }
  return base;
}

export function i18nMiddleware(config: MiddlewareConfig) {
  const baseT = createTranslator(config.dictionaries[config.baseLocale]);

  return (req: Request, res: Response, next: NextFunction) => {
    const locale = pickLocale(
      req.header("accept-language"),
      config.supportedLocales,
      config.baseLocale
    );

    const t = createTranslator(config.dictionaries[locale]);
    const tSafe = createSafeTranslator(t, locale, {
      strict: config.strict,
      onError: config.strict ? "throw" : "fallbackToBase",
      baseT,
      logger: config.logger
    });

    (req as any).locale = locale;
    (req as any).t = t;
    (req as any).tSafe = tSafe;

    res.locals.locale = locale;
    res.locals.t = tSafe; // convenient for server-rendered views

    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

Usage in your app:

import express from "express";
import en from "./locales/en.json" with { type: "json" };
import fr from "./locales/fr.json" with { type: "json" };
import { i18nMiddleware } from "./i18n/middleware.js";

const app = express();

app.use(
  i18nMiddleware({
    dictionaries: { en, fr },
    baseLocale: "en",
    supportedLocales: ["en", "fr"],
    strict: process.env.STRICT_I18N === "true",
    logger: (info) => console.warn(JSON.stringify(info))
  })
);
Enter fullscreen mode Exit fullscreen mode

Now every request has req.tSafe(key, vars) and your templates can use res.locals.t(...).

2.4 Real-world examples: emails, UI strings, and analytics events

Emails: the most common place placeholder drift becomes expensive.

// email/sendResetEmail.ts
type ReqWithI18n = { tSafe: (key: string, vars?: any) => string };

export async function sendResetEmail(req: ReqWithI18n, to: string, name: string, link: string) {
  const subject = req.tSafe("email.reset.subject", { name });
  const body = req.tSafe("email.reset.body", { name, link });

  // send with your provider...
  return { to, subject, body };
}
Enter fullscreen mode Exit fullscreen mode

If French drops {{link}} or renames {{name}}, you’ll catch it in strict mode (CI/dev). In production, you can fallback to English rather than sending a broken email.

UI / API responses:

app.get("/api/not-found", (req, res) => {
  const t = (req as any).tSafe;
  res.status(404).json({
    code: "NOT_FOUND",
    message: t("errors.notFound", { resource: "Page" })
  });
});
Enter fullscreen mode Exit fullscreen mode

Analytics: placeholder bugs here can silently corrupt dashboards.

function track(event: string, props: Record<string, any>) {
  // your analytics client
}

app.post("/checkout/start", (req, res) => {
  const t = (req as any).tSafe;
  const plan = "Pro";

  track("Checkout Started", {
    label: t("analytics.checkoutLabel", { plan }), // avoid raw {{plan}} leaking
    locale: (req as any).locale
  });

  res.json({ ok: true });
});
Enter fullscreen mode Exit fullscreen mode

Runtime checks are great, but you don’t want to rely on runtime to find issues. Next: enforce placeholder parity in CI.


3) Automated checks: CI validation of placeholder parity across locales + safe fallbacks

3.1 CI script: validate placeholder parity for every key across locales

The CI validator should answer one question:

For every key, do all locales have the same placeholder set as the base locale?

To do that, you need to flatten nested JSON keys.

// scripts/validate-i18n.ts
import fs from "node:fs";
import path from "node:path";
import { extractPlaceholders } from "../i18n/placeholders.js";

function flatten(obj: any, prefix = "", out: Record<string, any> = {}) {
  for (const [k, v] of Object.entries(obj)) {
    const key = prefix ? `${prefix}.${k}` : k;
    if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
    else out[key] = v;
  }
  return out;
}

function loadJson(p: string) {
  return JSON.parse(fs.readFileSync(p, "utf8"));
}

const localesDir = path.join(process.cwd(), "locales");
const baseLocale = "en";

const localeFiles = fs
  .readdirSync(localesDir)
  .filter((f) => f.endsWith(".json"))
  .map((f) => ({ locale: path.basename(f, ".json"), file: path.join(localesDir, f) }));

const dicts: Record<string, any> = {};
for (const { locale, file } of localeFiles) dicts[locale] = loadJson(file);

if (!dicts[baseLocale]) {
  console.error(`Base locale "${baseLocale}" not found in ${localesDir}`);
  process.exit(2);
}

const baseFlat = flatten(dicts[baseLocale]);
const baseKeys = Object.keys(baseFlat);

let failed = false;

for (const { locale } of localeFiles) {
  if (locale === baseLocale) continue;
  const flat = flatten(dicts[locale]);

  for (const key of baseKeys) {
    const baseVal = baseFlat[key];
    const val = flat[key];

    // Optional: enforce key presence
    if (val === undefined) {
      console.error(`[${locale}] Missing key: ${key}`);
      failed = true;
      continue;
    }

    // Enforce string type parity (optional but useful)
    if (typeof baseVal === "string" && typeof val !== "string") {
      console.error(`[${locale}] Key not a string: ${key}`);
      failed = true;
      continue;
    }

    if (typeof baseVal !== "string") continue; // ignore non-string branches

    const basePH = extractPlaceholders(baseVal);
    const ph = extractPlaceholders(val);

    const missing = [...basePH].filter((p) => !ph.has(p));
    const extra = [...ph].filter((p) => !basePH.has(p));

    if (missing.length || extra.length) {
      console.error(
        `[${locale}] Placeholder mismatch: ${key}\n` +
          `  base(${baseLocale}): ${JSON.stringify([...basePH])}\n` +
          `  ${locale}: ${JSON.stringify([...ph])}\n` +
          (missing.length ? `  missing: ${missing.join(", ")}\n` : "") +
          (extra.length ? `  extra: ${extra.join(", ")}\n` : "")
      );
      failed = true;
    }
  }
}

process.exit(failed ? 1 : 0);
Enter fullscreen mode Exit fullscreen mode

Add it to package.json:

{
  "scripts": {
    "i18n:validate": "node --enable-source-maps scripts/validate-i18n.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

Run it in CI with STRICT_I18N=true (for runtime tests) and npm run i18n:validate (for file parity).

Next, let’s talk about the hard part: plurals/select.

3.2 Handling plural/select and complex messages (ICU) without false positives

If you use ICU MessageFormat, placeholders aren’t just {{name}}. You’ll see patterns like:

{count, plural,
  one {You have {count} message}
  other {You have {count} messages}
}
Enter fullscreen mode Exit fullscreen mode

A naive regex will miss variables, or worse, extract garbage. The fix is to parse ICU into an AST and collect variables properly. In Node, you can use packages such as:

  • @formatjs/icu-messageformat-parser (AST parsing)
  • intl-messageformat (formatting + parsing support in the FormatJS ecosystem)

Your CI script should then:

  • Parse each message
  • Collect all variable names used
  • Optionally validate that each plural branch includes the required variables (depending on your policy)

Even if you stick with Mustache for now, plan ahead: plural/select is where teams get tempted to “just tweak the string,” and drift becomes more likely. If you’re using Shipi18n (or any TMS), it’s worth enabling ICU-aware validation at import/export time so translators can’t accidentally break syntax.

3.3 Production-safe fallbacks: degrade gracefully but surface signals

Even with CI checks, production needs a safety net. Files can be hotfixed, experiments can introduce new keys, or a third-party translation sync can go wrong.

A good production policy:

  • For critical paths (emails, payments): consider onError: "fallbackToBase" or even "throw" if you prefer to block rather than send wrong content.
  • For UI microcopy: "fallbackToBase" is usually fine.
  • For analytics: "sentinel" can be better than empty strings because it keeps issues visible in dashboards.

Whatever you choose, always emit signals:

  • Structured logs with { key, locale, missing, extra, release }
  • Sentry events tagged by key and locale
  • Metrics like:
    • i18n_placeholder_mismatch_total{locale,key}
    • i18n_fallback_used_total{locale,key}

Because tSafe() already takes a logger, wiring this up is straightforward.


4) Production workflow: integrating Shipi18n with translators, versioning, and monitoring regressions

4.1 Source-of-truth and versioning strategy (keys, placeholders, and releases)

Once you treat placeholders as an API contract, you need a lightweight versioning mindset:

  • Base locale (en) defines the contract for each key.
  • Placeholder changes are breaking changes for that key:
    • rename {{orderId}}{{orderID}} = breaking
    • remove {{name}} = breaking
    • add {{company}} = breaking unless you guarantee it’s always provided

In practice, teams handle this by:

  • Keeping locale files in the repo
  • Requiring PRs for translation updates
  • Running placeholder parity validation in CI
  • Adding a checklist item in PR templates, e.g. “Did you change placeholders? If yes, update all locales or add a migration plan.”

This keeps changes visible and enforceable.

4.2 Shipi18n integration: syncing keys, protecting placeholders, and translator guidance

A TMS can reduce drift if it understands placeholders as protected tokens. Shipi18n fits naturally here because you can structure your workflow so placeholders are:

  • Marked as non-editable tokens (translators can move them, but not rename them)
  • Validated on import/export (placeholder parity checks before content lands in your repo)
  • Documented per key (translator notes: what {{name}} represents, capitalization rules, etc.)

Even without a specific config format, the principle is consistent: make placeholders first-class in the translation UI. Provide examples like:

  • “Use {{orderId}} exactly; do not change casing.”
  • “Keep {{count}} in all plural forms.”

This reduces drift at the source, not just at the build gate.

4.3 Monitoring and alerting: catch regressions fast in production

Finally, close the loop with monitoring. Add:

  • A counter for placeholder mismatches and fallbacks
  • Alerts on spikes after deploys
  • A post-deploy canary that renders critical templates across your top locales (password reset, order confirmation, invoice generation)

A simple canary can be as small as a script that loads your locale files and renders a set of known keys with sample variables. The goal is to catch “only breaks in fr-CA” issues minutes after a release, not days.


Conclusion

Placeholder drift is expensive because it hides in plain sight: a translation diff that looks harmless, until runtime hits the wrong locale and the wrong code path.

The fix is to treat placeholders like an API contract and enforce that contract everywhere:

  • Add a tSafe() helper so correct placeholder usage is the default.
  • Attach it via Express middleware so every request gets a request-scoped, locale-aware safe translator.
  • Validate placeholder parity in CI across all locales to fail fast before production.
  • In production, degrade safely (fallback/sentinel) and emit logs + metrics so nothing fails silently.
  • If you use a TMS like Shipi18n, protect placeholders and validate them during sync to prevent drift before it reaches your repo.

Pick one critical template this week—usually a password reset or order confirmation email—wrap it with tSafe(), add the CI validator script, and expand from there. Once you’ve felt the relief of never shipping broken placeholders again, you won’t go back.

Top comments (0)