i18n JSON Checks: 9 Ways to Catch Missing Keys
i18n JSON files break quietly: a key missing from en.json leaves a button empty, and a stray {name} placeholder ships without a value.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 The two-file language pack
- 02 Keys present in one locale only
- 03 {placeholder} mismatches
- 04 Untranslated Korean left behind
- 05 The t() function that wires it up
- 06 Hardcoded text that skips the pack
- 07 Raw keys leaking to the screen
- 08 Per-language URLs and hreflang
- 09 Dates and numbers per locale
The nine follow the order a second locale actually breaks in. Start with the two-file skeleton (01) and the keys that exist on only one side (02), then look inside the sentences for placeholder mismatches (03) and untranslated Korean left behind (04). Check the t() function that wires the pack to the screen (05), catch hardcoded text that skips the pack entirely (06) and raw keys leaking through (07), then step outside the page to per-language URLs and hreflang (08) and date and number formats (09).
01The two-file language pack
Keys from ko.json and en.json fan out into matching rows so the two files line up side by side, and a key that exists on only one side leaves an empty slot that catches the eye immediately. Run this the first time a second locale gets wired up, or whenever a new translation file is added.
const flatten = (o, p = "") =>
Object.entries(o).flatMap(([k, v]) =>
v && typeof v === "object"
? flatten(v, p ? `${p}.${k}` : k)
: [`${p ? p + "." : ""}${k}`]
);
const load = async (l) =>
flatten(await (await fetch(`/locales/${l}.json`)).json());
const ko = await load("ko");
const en = await load("en");
console.log(ko.length, en.length); // if the counts differ, the skeleton itself is off
02Keys present in one locale only
Key rows in both columns light up and fill in order, but the one missing on either side stays dark while a red defect mark points at the gap. It's the check for when a button comes back empty only in English, or when a new key only made it into Korean.
const onlyIn = (a, b, label) => {
const set = new Set(b);
return a.filter((k) => !set.has(k)).map((k) => `${label}: ${k}`);
};
const gaps = [
...onlyIn(ko, en, "ko-only"),
...onlyIn(en, ko, "en-only"),
];
console.log(gaps);
// → a line like "ko-only: nav.mypage" for every key missing on one side
03{placeholder} mismatches
A {name} chip inside the sentence highlights like a marker pen while each locale gets swept, and a sentence missing that chip gets a warning badge that shakes into place. Useful when a greeting drops the user's name in one language, or when a quantity or date variable leaks out raw.
const ph = (s) => [...String(s).matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
const sameSet = (a, b) =>
a.length === b.length && a.every((x) => b.includes(x));
const koOrder = "{name}님, 주문이 {count}건 접수되었습니다";
const enOrder = "{count} orders placed for {name}";
console.log(sameSet(ph(koOrder), ph(enOrder))); // true
const koRefund = "{date}에 환불이 완료되었습니다";
const enRefund = "Refund completed";
console.log(sameSet(ph(koRefund), ph(enRefund))); // false: {date} is missing
04Untranslated Korean left behind
A scan line sweeps down the English lines and dyes only the Hangul syllables red as it passes, ticking a residue counter up with every hit. It's the check to run right after machine translation, whenever Korean still shows through on the English page.
const hangul = /[가-힣]/;
const enJson = await (await fetch("/locales/en.json")).json();
const residue = Object.entries(enJson).filter(
([k, v]) => typeof v === "string" && hangul.test(v)
);
console.log(residue);
// → [["shipping", "배송이 시작되었습니다."], ["refund", "환불이 처리되었습니다."]]
05The t() function that wires it up
Key cards ride a conveyor into the t() box and come out as the current language's phrase, while a locale lever flips between ko, en, and ja to swap the output for the same key. These eight lines are the baseline for wiring language files to the screen, and for spotting a typo'd key that returns an empty string.
const get = async (l) => await (await fetch(`/locales/${l}.json`)).json();
const packs = { ko: await get("ko"), en: await get("en"), ja: await get("ja") };
let lang = "ko";
function t(key, vars = {}) {
let s = packs[lang][key] ?? packs.ko[key] ?? key;
for (const [k, v] of Object.entries(vars)) {
s = s.replaceAll(`{${k}}`, v);
}
return s;
}
console.log(t("cta.start")); // Get started
console.log(t("cta.startX")); // cta.startX unchanged → signals a typo
06Hardcoded text that skips the pack
A scan line walks the code lines one by one, and only the hardcoded Korean stuck between tags freezes red while the rest slide past untouched. It catches text that survives a language switch, including labels an AI assistant dropped straight into the markup.
// Korean text stuck between tags = it skipped the language pack
const between = />\s*[가-힣][^<]*</g;
const hits = [...new Set(
(document.body.innerHTML.match(between) || [])
.map((s) => s.slice(1, -1).trim())
)];
console.log(hits);
// → ["구매하기", "자주 묻는 질문", "배송 안내"]
07Raw keys leaking to the screen
A screen card shows the raw key welcome.title, then a fallback phrase pushes up from below to replace it before the raw key returns, looping between the two states. Run it when a button slot literally shows header.title, or when deciding the fallback-language rule.
const keyShape = /^[a-z0-9]+(\.[a-z0-9]+)+$/;
const leaks = [...document.querySelectorAll("button, a, h2, p, span")]
.filter((el) => el.children.length === 0)
.map((el) => el.textContent.trim())
.filter((s) => keyShape.test(s));
console.log(leaks); // → text like "welcome.title" leaking out in raw key shape
08Per-language URLs and hreflang
A link trace orbits between the /, /en/, and /ja/ nodes pointing at each other, until the one broken direction sprouts a red question mark that flags the one-way link. It's the fix for when only the English version gets indexed, or a language tab lands on a 404.
const want = ["ko", "en", "ja"];
const have = new Set(
[...document.querySelectorAll('link[rel="alternate"][hreflang]')]
.map((t) => t.getAttribute("hreflang"))
);
const missing = want.filter((l) => !have.has(l));
console.log(missing); // → ["ja"] means this page has no bridge over to ja
09Dates and numbers per locale
One date card flips like an arrival board through the ko, en, and ja formats, swapping thousands separators along the way. Reach for it when order dates look odd, or a price's comma lands in the wrong spot — and let Intl handle the formatting instead of hand-rolling it.
const d = new Date(2026, 8, 11); // September 11 (months are 0-indexed)
const price = 1234567;
for (const lang of ["ko", "en", "ja"]) {
const date = new Intl.DateTimeFormat(lang, { dateStyle: "long" }).format(d);
const num = new Intl.NumberFormat(lang).format(price);
console.log(lang, date, num);
}
Where it breaks — key-shaped domains fool the raw-key check
This is what happened when check 07 ran against this very site. It correctly caught text that had leaked out as a raw key, but it also flagged plain link text reading "godrichstory.com" — a run of dotted lowercase characters matches the same shape as a key, so shape alone always lets a few false positives through. The fix is to cross-check whatever gets flagged against the real key list from check 02's flatten output: if the string shows up there, it's a genuine leak, and if it doesn't, it's just a domain or version string that happens to look the same. The zip's nine folders open with the password fnn2hd5e, typed exactly as it appears on screen. Check 08 hides a matching trap — confirming a hreflang tag exists on every page misses whether the per-language URLs actually point back at each other, and this page can link forward to ja while the ja page carries no return link, which search engines read as a one-way relationship rather than a pair.
Accessibility
All nine fall back to animation: none under prefers-reduced-motion: reduce, and each stops on a frame that still makes sense. Items 01, 02, 04, and 06 freeze just the scanning or filling motion, leaving the empty slots and red residue or badges in place so the problem stays visible. Item 07 stops with the fallback phrase already shown, and item 08 stops with the broken arc and question mark visible, while item 09 parks on the ko-formatted card. The setup mirrors MDN's prefers-reduced-motion page.
FAQ
Do I need to know how to code to run these checks?
No — paste any of the nine straight into your browser's developer console and the result prints immediately. The zip also bundles all nine into a single script, so swapping in your own site's URL is the only change needed. How this site verifies its own demos and scripts is written up on the About page.
Will running these checks change anything on the site or slow it down?
No. Every one of them only reads the screen or a file, and none of them writes a value or sends anything to a server. The fetch calls that pull in the language pack JSON just re-read what the browser already cached, so there's no noticeable slowdown.
Does this still work if the site is built with React?
Yes. Checks 01 through 04 look only at the language pack JSON files, so it doesn't matter what rendered the screen. Check 05's t() is a bare-bones example — if the site uses a library like react-i18next, apply the same idea to that library's function instead. More checks like this turn up under "i18n" on site search.