Keys, catalogs, and the no-concatenation rule
The foundational discipline of internationalization with next-intl, holding stable keys in code while translatable text lives in per-language catalogs, so a second language is a file, not a rewrite.
Here’s a line your app renders a hundred times a day in an inbox header, completely unremarkable:
<p>Welcome, {user.name}! You have {count} unread messages.</p>Then a sales deal makes French a launch requirement, and someone has to make that line speak French. The obvious version writes itself:
'Bienvenue, ' + user.name + '! Vous avez ' + count + ' messages non lus.';The French is correct, the variables sit in the right spots, and it renders. But it’s broken in three ways that throw no error and won’t surface until they’re in front of paying customers.
- The word order is frozen in English’s. German puts the verb at the end of the clause, and many languages reorder subject and object. Concatenation glues the pieces in a fixed sequence, name then count then noun, and nothing downstream can move them.
- The plural is hand-rolled.
count === 1 ? 'message' : 'messages'is an English rule. Russian has four plural forms and Arabic has six, so that branch is silently wrong in most languages you’ll support. - There is nowhere for a translator to stand. The French text lives inside a
.tsfile, welded between+operators. The person whose job is translating this sentence doesn’t read code and can’t open this file without breaking it.
This lesson installs the one shape that fixes all three: a key your code holds, paired with a translation entry in a per-language file the translator owns, with named slots that file can reorder, and zero concatenation across translatable text. Learn it once and the first language and the fortieth cost the same. It’s the move you made last chapter when you stopped deriving a user’s timezone per request and started passing an explicit, validated value to every formatter. i18n is that discipline pointed at text instead of time.
You won’t install a library or configure anything here; that comes a few lessons out. You’ll see the exact function calls you’ll eventually write, named once so they’re familiar when they arrive. Get the shape right and a single-language launch already has everything in place, so the second language is one pull request rather than a refactor across every component.
The three parties a string passes through
Section titled “The three parties a string passes through”A user-visible string isn’t a value one function produces. It’s a contract between three parties, each with one job, and almost every rule in this lesson follows from keeping those jobs separate.
- The engineer is you.
You write a key (
t('inbox.greeting')) and pass named values ({ name, count }). You name what goes where; you don’t author the words, and you never write the translation. - The catalog is the per-language file (
messages/en-US.json,messages/fr-FR.json). It is the wire format between the other two: it holds the actual text, keyed by the same string your code holds. - The translator is a person, often non-technical, working inside a separate tool. They edit the text inside each entry and reorder the named slots so the sentence reads naturally in their language, without ever opening a component or running the app.
This is the line the rest of the chapter is built on: keys are the wire format, and strings are the rendered output.
Your code holds t('invoice.pastDue.title') forever; the catalog holds whatever “Invoice past due” becomes in each language.
Assemble a translatable string by concatenation in component code and you collapse three parties into one, handing the translator’s job to the + operator, the bug class this chapter exists to prevent.
t('inbox.greeting', { count }) the engineer "You have {count} unread messages" Keys in code, text in the catalog
Section titled “Keys in code, text in the catalog”The fix for the broken line from the opening isn’t more code, it’s a split: the sentence leaves the component for the catalog, and a key plus the values it needs stay behind.
'Bienvenue, ' + user.name + '! Vous avez ' + count + ' messages non lus.';The trap. Word order is frozen, the plural is hand-rolled, and a translator has no way in: the sentence is welded into source code.
// componentt('inbox.greeting', { name: user.name, count });
// messages/en-US.json"greeting": "Welcome, {name}! You have {count} unread messages."The shape. The component names the slots; the catalog owns the sentence and reorders those slots per language.
Read the corrected version as a handoff. Your code says “render inbox.greeting, and here are its two values, a name and a count”; it says nothing about the wording. The catalog answers that, "Welcome, {name}! You have {count} unread messages." in English and whatever the translator writes in French. The key is the contract: neither side knows what the other says, only that they agree on the name.
That t comes from next-intl , which you’ll wire up in a later lesson; for now only the call shape matters. In a Client Component you reach for a hook:
const t = useTranslations('inbox');// ...t('greeting', { name: user.name, count });useTranslations('inbox') scopes t to the inbox namespace, so t('greeting') resolves to inbox.greeting. Its async server-side sibling is getTranslations, and we’ll place each at the end of the lesson. Either way the call shape is the same: a key, then an object of named values.
For now, treat {count} as exactly what {name} is, a named slot the engineer fills and the translator positions in the sentence. It will eventually carry plural logic, since most languages don’t pluralize the way English does and the catalog is where that logic lives. Those rules are ICU MessageFormat , the next lesson’s subject. Here, a slot is just a slot.
What a translation key looks like
Section titled “What a translation key looks like”A key is a flat, dot-separated path. Three real examples:
t('invoice.pastDue.title');t('inbox.unread.count');t('auth.signIn.cta');Two parts do the work. The namespace (invoice, inbox, auth) groups strings by feature and mirrors your feature folders, so a surface’s strings live under the same predictable path as its components. The leaf (title, count, cta) names the role the string plays, not its English words: auth.signIn.cta is “the call-to-action on the sign-in screen,” not auth.signIn.signIn and not the English text itself.
Keep keys to two or three levels. That groups cleanly and stays greppable; five-level keys turn into noise you can’t search, harder to scan than the sentence they point at.
Keys are stable; catalog values are mutable. You can rewrite the English for invoice.pastDue.title from “Invoice past due” to “This invoice is overdue” by editing one line in en-US.json, no component touched. Renaming the key is a coordinated edit across the component and every language file at once, since all of them are pinned to that name. If the key were the English sentence, every copy tweak would become exactly that rename across your whole catalog set.
A fixed set of keys is also enforceable by tooling. next-intl generates a type from en-US.json, so t('invoice.pastDue.title') type-checks and t('invoice.pastDue.ttile') is a compile error: a typo becomes a red squiggle in your editor, not a string that silently renders nothing in production. A lint rule (eslint-plugin-i18n-json) catches the other direction, keys in code but missing from a catalog, or stranded in a catalog with nothing referencing them.
Try the drill below: each blank is a real string that needs a key, and the wrong options are the four ways this commonly goes wrong.
Pick the best translation key for each string. Pick the right option from each dropdown, then press Check.
The past-due-invoice banner needs a key for its heading. The well-formed choice is .
On the sign-in screen, the submit button’s label is keyed as .
And the inbox shows an unread-count line, whose key is .
Named placeholders, never positional
Section titled “Named placeholders, never positional”A named slot ({count}, {name}) carries two things a position can’t: a meaning and a stable identity. When the translator writes the French entry, "Vous avez {count} messages non lus.", they keep the slot’s name and move it elsewhere in the sentence. German can invert subject and verb and the slot follows, still called {count}, still meaning the unread count. The translator sees {count} and knows both what it is and where it’s free to go.
The alternative is the positional placeholder, inherited from C’s printf:
'You have %s unread %s';The translator gets two %s tokens identified only by their order. They can’t move them, because order is fixed by the argument list your code passes; they can’t rename them, because they have no names; they can’t even tell what either one means. The slot has been stripped down to its position, the one property that doesn’t survive translation. Concatenation fails for the same reason: gluing fragments with + is positional by construction, the pieces frozen in the order you wrote them, which is English’s order.
The cheapest proof is one key, one {count} slot, three languages. Watch where the slot lands.
"count": "You have {count} unread messages""count": "Vous avez {count} messages non lus""count": "Du hast {count} ungelesene Nachrichten"Three sentences, three shapes, one unchanging call in your code: t('inbox.unread.count', { count }). The engineer never reordered anything; the catalog did, three times, and the code never noticed. Named placeholders are the rule that makes this work. The next lesson covers ICU MessageFormat, the syntax that lives inside a slot once it needs to do more than hold a value.
Catalog files: one JSON file per language
Section titled “Catalog files: one JSON file per language”You’ve seen fragments of the catalog. Here’s the whole shape.
One JSON file per language. With messages/en-US.json, messages/fr-FR.json, and messages/de-DE.json, a language’s entire catalog lives in one file, and you organize within it using nested objects. This is next-intl’s recommendation and the default this stack uses. Splitting a language across several files merged at runtime is a supported option for very large catalogs, but it isn’t where you start.
The nesting is the dot-path made literal: a key like invoice.pastDue.title is just three nested objects deep. Walk the file below one piece at a time.
{ "invoice": { "pastDue": { "title": "Invoice past due", "body": "Payment of {amount} was due on {dueDate}." } }, "auth": { "signIn": { "cta": "Sign in" } }}The top-level key is the feature namespace, and everything for invoices nests under here. This is the invoice in invoice.pastDue.title.
{ "invoice": { "pastDue": { "title": "Invoice past due", "body": "Payment of {amount} was due on {dueDate}." } }, "auth": { "signIn": { "cta": "Sign in" } }}A leaf is the actual string. The full path to this value is invoice.pastDue.title: read the nesting top to bottom and you have the key.
{ "invoice": { "pastDue": { "title": "Invoice past due", "body": "Payment of {amount} was due on {dueDate}." } }, "auth": { "signIn": { "cta": "Sign in" } }}A value can carry named slots, just like in the component call. The engineer passes { amount, dueDate }; the translator places them in the sentence.
{ "invoice": { "pastDue": { "title": "Invoice past due", "body": "Payment of {amount} was due on {dueDate}." } }, "auth": { "signIn": { "cta": "Sign in" } }}A second namespace sits beside the first. auth.signIn.cta lives in the same file: one file per language, many namespaces inside it.
Why JSON? The catalog isn’t read only by your code; it’s read and edited by the tools translators work in. Every TMS (Lokalise, Crowdin, Tolgee, Phrase) round-trips JSON cleanly, and the next-intl ecosystem standardizes on it.
One last property matters: the catalogs live in your repository, ship in your build, and are version-controlled alongside the code that uses them, not fetched from a remote service at runtime. This is why renaming a key is a coordinated commit. The catalog is code-adjacent: the key in the component and the entry in en-US.json move together, in the same reviewed pull request.
Reuse keys by meaning, not spelling
Section titled “Reuse keys by meaning, not spelling”Beginners get this rule wrong in both directions, so it’s the most nuanced one here. Once keys click, the instinct is to deduplicate: forty buttons across the app say “Save,” so surely they all call t('common.save'). That feels like good engineering. It’s the canonical i18n bug.
The rule: key reuse follows meaning, not English spelling. Share a key when two surfaces show the same message in the same role. Mint a new key when they merely share an English word but mean different things, because a translator may need them to diverge and a shared key takes that choice away.
Walk the common.save trap. In English all forty buttons read “Save,” so one key seems to lose nothing. Hand the catalog to a German translator. The Save on the settings page means save these preferences: Speichern. But one of those buttons is the final action in checkout, where “Save” means place the order, which German words entirely differently. A shared key forces both to the same translation, so settings reads fine and checkout reads wrong. The bug is invisible to you: the English is identical, nothing fails, and you don’t read German. It surfaces only when a German-speaking customer reaches checkout and the button says the wrong thing.
The opposite mistake is splitting a key that genuinely is one message. If errors.unauthorized shows the same way whether the user was blocked from an invoice or a customer record, splitting it into errors.unauthorized.invoice and errors.unauthorized.customer just makes the translator edit the same sentence twice, and the two copies can drift apart.
So run one test, a single question about the future:
Could a translator legitimately want these two strings to differ in some language?
If yes, they get separate keys even when today’s English is identical; if no, they share one key even when they live on different routes. Two keys may carry the same value, and that’s fine. What you protect is the translator’s freedom to make them diverge later without touching your code.
Sort the pairs below. Each is two surfaces sharing an English string; run the test before you drag.
Each pair of surfaces shares an English string. Decide whether they should share one translation key or get their own — ask: could a translator legitimately want them to differ in some language? Drag each item into the bucket it belongs to, then press Check.
The pairs that split are where one English word does two jobs: deleting a row is routine, but deleting your account is irreversible and a translator might phrase it far more carefully, and “Open” the verb has nothing to do with “Open” the invoice status. The pairs that share are one message playing the same role in two places. Spelling is a coincidence; meaning is the contract.
Require the source language, fall back per key
Section titled “Require the source language, fall back per key”This is the rule that lets a one-person team launch in one language today without closing the door on ten languages later.
Every key must have a value in the source language. On this stack that’s en-US, the full locale tag, not a bare en. A key referenced in code with no entry in the source catalog is a build error, so you find a missing string at your desk, not from a customer staring at a blank space.
Other languages may have gaps. When fr-FR.json is missing a key that en-US.json has, the runtime falls back to the source language and renders the English string rather than crashing or showing an empty box. A French user mid-translation sees mostly French with the occasional English line, a shippable in-between state, not an outage.
Those gaps aren’t silent: a missing-key lookup surfaces to the dev console while you build and to Sentry in production, giving the translation pipeline a concrete worklist. So you ship source-language-complete and let translators fill the other languages asynchronously through their TMS, without blocking a release.
Markup inside a string stays one key
Section titled “Markup inside a string stays one key”The rule has to stretch for one case, and the wrong instinct is natural enough to preview now: a string with an inline element inside it, a link, a <strong>, an icon.
'By signing up, you agree to our <link>Terms</link>.';The tempting move is to split it into three keys, a prefix (“By signing up, you agree to our ”), a linkText (“Terms”), and a suffix (”.”), then concatenate them in JSX with the <Link> in the middle. That’s concatenation again in disguise: same frozen word order, same broken contract, only now the fragments are JSX. Many languages won’t put the link where English does, and you’ve welded its position in place.
Keep it as one key and let the catalog own the whole sentence. The <link> tag lives in the catalog string, and your code supplies the component that tag maps to:
t.rich('terms.agreement', { link: (chunks) => <Link href="/terms">{chunks}</Link>,});The translator owns the sentence and where the link sits inside it; you provide only the component for the tag, and t.rich returns real rendered JSX rather than a string. Never reach for dangerouslySetInnerHTML on translated content: that is how injected HTML becomes an XSS hole, and t.rich exists so you never need it.
Counts and gendered forms stay in the catalog
Section titled “Counts and gendered forms stay in the catalog”Never branch on count === 1 in component code. A plural ternary smuggles English’s two-form grammar back into the code the rule just removed it from. Every count-shaped string is a single key whose catalog value carries the plural logic; the component passes { count } and nothing more:
t('inbox.unread', { count });// en-US.json: "unread": "{count, plural, ...}"Gendered and role-based variants work the same way: the catalog decides between them, never a switch in your component. The syntax inside that catalog value, the plural rules and language categories, is the next lesson. The discipline carries over: logic about how a string varies lives in the catalog, and the component only ever passes the values.
Which strings to translate
Section titled “Which strings to translate”You don’t want to translate your debug logs, so before you run the audit, know what’s in scope.
Translate anything a user reads. Text inside JSX (<p>...</p>), user-facing prop values (aria-label, title, placeholder, alt), toast and notification text, validation messages. If a human sees it, it’s a key.
Leave the machine-facing strings alone. Debug and server logs (those are for you, in one language). ARIA roles on structural elements. Machine-readable values, IDs, and enum tokens (the underlying 'PAST_DUE' value, not the label you render for it). URLs and route segments. None of these reach a user as prose, so routing them through t() buys nothing.
The reviewer’s reflex from the code conventions is worth memorizing: any string literal in JSX that isn’t a key is a finding. Hold it together with its inverse, or you’ll start “fixing” console.error calls: any machine-facing string stays exactly as it is. The skill is telling the two apart at a glance.
The component below mixes both kinds on purpose. Click every string that must become a translation key, and leave the machine-facing ones alone.
Click every string that must become a translation key, then press Check. Leave the machine-facing ones alone.
function InvoiceBanner({ status }: { status: 'PAST_DUE' | 'PAID' }) { if (status !== 'PAST_DUE') return null;
return ( <aside role="alert"> <h2>Invoice past due</h2> <a href="/invoices"> <OpenIcon /> </a> <button aria-label="Dismiss notification" onClick={dismiss}> × </button> </aside> );}
async function load(id: string) { const res = await fetch(`/invoices/${id}`); if (!res.ok) console.error('Failed to load invoice');}This is the audit a reviewer runs on a pull request and the grep you run across a codebase before a launch: find the user-visible literals, route them through t(), and leave the machine-facing strings untouched. The heading and aria-label are read by humans; the log line, the status token, and the route are read by machines. The boundary is “who’s the audience,” and once you see it that way it stops being a judgment call.
Calling translations on the server and the client
Section titled “Calling translations on the server and the client”The Server/Client Component boundary is unchanged: translations cross it the same way data does.
The split maps onto the two call shapes. Server Components call getTranslations, which is async because everything on the server can be. Client Components call useTranslations, a synchronous hook. Two functions, the same key in both.
const t = await getTranslations('inbox');return <p>{t('greeting', { name, count })}</p>;Async. await getTranslations once, then call t as usual.
'use client';const t = useTranslations('inbox');return <p>{t('greeting', { name, count })}</p>;Sync. useTranslations is a hook: no await, same t, same key.
Underneath sits a default worth knowing now: catalog data is server-only unless a Client Component asks for a key. The active language’s slice crosses to the client only where a Client Component consumes a key, so importing useTranslations never bundles your whole catalog into the page. How that scoping is wired is a later lesson.
The shape held all lesson: the key lives in code, the text lives in a per-language catalog, named slots let the catalog reorder, and nothing translatable is concatenated. From here the chapter fills the slots this one left open: the logic inside {count, plural, ...} values, formatters for numbers, dates, and currencies, how the active language is chosen per request, the library wiring that lands it all in Next.js, and the SEO surface that routes each language to the right user.
External resources
Section titled “External resources”The authoritative reference for the catalog shape, namespacing, and the t() call you just learned.
A forward reference for the next lesson: the syntax that lives inside a catalog entry's slots.
The lint guard that flags missing and orphaned keys — the tooling that enforces the contract.
The next-intl author's interactive course chapter on namespacing and structuring keys — the same rules, taught hands-on.
Eleven practitioner conventions for naming, namespacing, and never reusing keys, straight from a TMS vendor.
A localization engineer's case for why fragmenting a sentence breaks every language with a different word order.