Module-scope data objects — const plans = [{ name: Free, ... }] — are the single hardest case in React i18n. Here's the pattern every major i18n library converged on, and how Polyglot automates the refactor.
Every React codebase has this file. A const plans = [...] sitting at the top
of a component module, each entry holding human copy: name: "Free",
description: "For hobby projects", cta: "Get started". It's clean, it's
declarative, and the moment you try to internationalize the app, it's a
problem.
The obvious move is to wrap each string with t(...):
const plans = [
{ name: t("plans.0.name"), description: t("plans.0.description") },
];
export default function Pricing() {
return plans.map(...);
}
This crashes at import time with ReferenceError: t is not defined. t
comes from a React hook — useTranslations() in next-intl, useIntl() in
react-intl, a context in i18next. Hooks only exist inside a component
render. Your const plans runs when the module is imported, long before
any component mounts. There is no t in that scope and there never will
be.
You could hoist the array into the component body to put t in scope —
but that only works if the const is used by exactly one component in one
file. Exports, shared data, and multi-component usage all break the move.
This isn't a library deficiency. It's a fundamental tension: the data is loaded at module time, the translation function only exists at render time, and they can't meet in the middle.
Look at how the major React i18n libraries document this case and they all land in the same place: don't put strings in module-scope data. Put keys.
const plans = [
{ id: "free" },
{ id: "pro" },
];
export default function Pricing() {
const t = useTranslations("plans");
return plans.map((plan) => (
<>
<h2>{t(`${plan.id}.name`)}</h2>
<p>{t(`${plan.id}.description`)}</p>
<button>{t(`${plan.id}.cta`)}</button>
</>
));
}
Or, slightly looser, the keys live in the data itself:
const plans = [
{ nameKey: "plans.0.name", descKey: "plans.0.description" },
];
Both variants preserve the same invariant: strings don't exist in module-scope data. They live in the translation catalog.
This isn't cosmetic. It's structural:
.tsx file is not.extract, Lingui's
extract, i18next-parser — every extractor in the ecosystem assumes
strings appear inline in JSX or via a recognized marker. Free-floating
strings in object literals are the hard case, which is exactly why the
ecosystem routed around them.t() autocomplete gets you every valid key with zero drift.It's not a tradeoff. The "keys in data, strings in catalog" pattern is strictly better for every production concern — easier locale switching, cleaner translator workflow, better tooling, better types.
If you're doing it by hand, the refactor is two coordinated edits per entry:
name: "Free" → name: "plans.0.name".t(...):
{plan.name} → {t(plan.name)}.And then populate your catalog:
{
"plans": {
"0": { "name": "Free", "description": "For hobby projects" },
"1": { "name": "Pro", "description": "For growing teams" }
}
}
At runtime, plan.name evaluates to the string "plans.0.name", and
t("plans.0.name") looks that up in the nested catalog. No scope issues.
No missed strings. No re-engineering the data shape.
For a pricing page with four plans and eight fields each, that's 32 data edits + 32 call-site edits. Miss one and your UI ships an untranslated key name to the user. It's the kind of mechanical refactor that's exactly what you want a tool to do.
Polyglot's wrap command now performs this transform as a deterministic
two-sided AST rewrite, when it can prove every place the data is read.
Starting from src/app/pricing/page.tsx:
const plans = [
{ name: "Free", features: ["Up to 100 strings"], cta: "Get started" },
];
export default function Pricing() {
return (
<section>
{plans.map((plan) => (
<div>
<h2>{plan.name}</h2>
<ul>{plan.features.map((f) => <li>{f}</li>)}</ul>
<button>{plan.cta}</button>
</div>
))}
</section>
);
}
Running polyglot wrap (CLI 0.14.4) produces:
import { useTranslations } from "next-intl";
const plans = [
{ name: "src.app.pricing.page.plans.0.name", features: ["src.app.pricing.page.plans.0.features.0"], cta: "src.app.pricing.page.plans.0.cta" },
];
export default function Pricing() {
const t = useTranslations();
return (
<section>
{plans.map((plan) => (
<div>
<h2>{t(plan.name)}</h2>
<ul>{plan.features.map((f) => <li>{t(f)}</li>)}</ul>
<button>{t(plan.cta)}</button>
</div>
))}
</section>
);
}
Keys are namespaced by file path, so two files with a plans array never
collide. And messages/en.json:
{
"src": {
"app": {
"pricing": {
"page": {
"plans": {
"0": {
"cta": "Get started",
"features": {
"0": "Up to 100 strings"
},
"name": "Free"
}
}
}
}
}
}
}
A few things happen under the hood:
title, description, name, cta, features, …) are touched. CSS
classes, href, id, type — all values the i18n ecosystem has
long-agreed are not user copy — get left alone.{plan.name} wraps because its shape
plans.*.name is a leaf we rewrote. {plan.features} does not wrap
because its shape plans.*.features points to a container, not a
string..map((plan) => ...) tracks the plan
binding's element shape. Nested maps like .map((f) => <li>{f}</li>)
correctly identify f as a leaf and wrap it.key={plan.name},
console.log(plan.name), a comparison — rewriting the value would
change that behavior too. So wrap leaves the whole array untouched
and flags it in the report as unproven-consumer, with the exact
location..map(({ name }) => ...) would require renaming every name in scope
to distinguish "the destructured field" from "any other variable named
name" — we punt rather than guess.If you've been blocked on i18n because of one exported array of strings
somewhere in your codebase, this is the refactor that unblocks you. Run
polyglot wrap --dry-run to see the plan, then polyglot wrap. Review
the diff and the report, run your app, and commit. Anything wrap
couldn't prove safe is listed in polyglot-i18n-report.md for you to
finish by hand.
curl -fsSL https://getpolyglot.ai/install.sh | bash
cd your-app
polyglot scan # See what you're missing
polyglot wrap --dry-run # Preview the rewrite
polyglot wrap # Rewrite to the catalog pattern
polyglot translate --languages de
Your first 50 strings translate free in one language, no account needed.
The catalog's yours, the refactor is reversible with polyglot undo <run-id> (the ID is printed
when wrap finishes), and your data arrays finally stop being the thing
that blocks localization.
Start in your terminal
Install the CLI, run a scan, and see exactly what you're missing. Free, no account required.