Skip to content
Chapter 84Lesson 1

Keys, catalogs, and the no-concatenation rule

The foundational discipline of internationalization, holding stable keys in code while translatable text lives in per-language catalogs, so adding a second language is a file and 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>

It works, it ships, and nobody thinks about it again. Then a sales deal makes French a launch requirement, and someone has to make that same line speak French. A competent developer who has never shipped a second language writes the obvious version without hesitating:

'Bienvenue, ' + user.name + '! Vous avez ' + count + ' messages non lus.';

The French is correct, the variables are in the right spots, and it renders. But it is broken in three ways that won’t show up in your browser, won’t throw an error, and won’t surface until they’re already in front of paying customers.

  • The word order is frozen in English’s order. German puts the verb at the end of the clause, and plenty of languages reorder subject and object. But concatenation glues the pieces in a fixed sequence, name then count then noun, and nothing downstream can move them. You’ve baked one language’s grammar into the structure of the code.
  • The plural is hand-rolled. “1 message” versus “5 messages” is an English if. Russian has four plural forms, and Arabic has six. The branch you’d reach for, count === 1 ? 'message' : 'messages', is silently wrong in most of the languages you’ll eventually support.
  • There is nowhere for a translator to stand. The French text lives inside a .ts file, welded between + operators. The person whose entire job is translating this sentence doesn’t read code, doesn’t run your app, and can’t open this file without breaking it. You’ve put the one string they need behind the one wall they can’t cross.

The real question hiding underneath that snippet isn’t how do I add French. It’s what happens to this string when it leaves my hands? It will be handed to a translator who inverts subject and verb in their language, who needs six plural forms, and who has never seen your codebase and never will. Once you ask the question that way, concatenation stops looking like a shortcut and starts looking like a trap you’re setting for a future version of yourself.

This lesson installs the one shape that disarms it: 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. This is the move you already made in the 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 same discipline pointed at text instead of time: an explicit input threaded to the edge, never welded into the code that surrounds it.

One note on scope. This lesson teaches the rule, not the wiring. You won’t install a library or configure anything here, since 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, but the goal today is the shape, not the setup. 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 you’ve ever written.

Before any rule, here is the mental model the rules hang off of. A user-visible string isn’t a value one function produces. It’s a small contract between three parties, each with exactly 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 never write the sentence the user reads, and you never write its translation. You name what goes where; you don’t author the words.
  • The catalog is the per-language file (messages/en-US.json, messages/fr-FR.json). This is the wire format between the other two parties. It holds the actual text, keyed by the same string your code holds.
  • The translator is a person, often non-technical, frequently working inside a separate tool. They open the catalog, edit the text inside each entry, and reorder the named slots so the sentence reads naturally in their language. They never open a component, and they never run the app.

Here is the line the rest of this chapter is built on: keys are the wire format, and strings are the rendered output. Your application code holds t('invoice.pastDue.title') forever. The catalog holds whatever “Invoice past due” becomes in each language. The moment a translatable string is assembled by concatenation in component code, you’ve collapsed three parties into one and handed the translator’s job to the + operator, which is exactly the bug class this entire chapter exists to prevent.

Keep the following diagram in your head for the whole lesson. Every rule that follows is a property of one of these three boxes or the arrows between them.

Component code t('inbox.greeting', { count }) the engineer
Catalog en-US.json / fr-FR.json "You have {count} unread messages"
Rendered UI You have 3 unread messages
Translator
One string, three owners. The engineer holds the key and passes named values; the catalog is the wire format that holds the text; the translator edits inside it and moves the slots. The user sees only the rendered string. Every rule in the lesson is a property of one of these boxes or the arrows between them.

Here is the first concrete rule, applied to the broken line from the opening. The fix isn’t more code, it’s a split. The sentence comes out of the component entirely and moves into the catalog, and what stays behind is a key and the named values the sentence will need.

The two panels below are the before and after. The first is the concatenation you already know is a trap; the second is the shape that replaces it.

'Bienvenue, ' + user.name + '! Vous avez ' + count + ' messages non lus.';

The trap. Frozen word order, a hand-rolled plural, and no entry point for a translator: the sentence is welded into source code.

Read the corrected version as a handoff. Your code says “render the entry called inbox.greeting, and here are the two values it’ll need: a name and a count.” It says nothing about what the sentence is. 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 between the two. Neither side needs to know what the other says, only that they agree on the name.

That t is the function the library hands you. The library is next-intl , and you’ll wire it up properly in a later lesson. For now, only the call shape matters, because that’s the form you’ll be writing. 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. You’ll see why keys are namespaced in a moment. There’s a sibling, getTranslations, for the async server side, and we’ll get to where each one belongs at the end of the lesson. The call shape is the same either way: a key, then an object of named values.

One thing to head off now, because it’s about to look like a loose end. That {count} in the catalog value will eventually carry more weight, because most languages don’t pluralize the way English does, and the catalog is where that logic will live. But not today. For this lesson, treat {count} as exactly what {name} is: a named slot the engineer fills with a value and the translator positions in the sentence. The plural rules that wrap around it are ICU MessageFormat , and they’re the next lesson’s entire subject. Here, a slot is just a slot.

A key is a flat, dot-separated path. Its shape is a deliberate decision with a real failure mode behind it, not just a style preference. Three real examples:

t('invoice.pastDue.title');
t('inbox.unread.count');
t('auth.signIn.cta');

Two things are doing work here. The namespace (invoice, inbox, auth) groups strings by feature, and it mirrors your feature folders, so the strings for a surface live under a predictable path the same way the components do. 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.” It is not auth.signIn.signIn, and it is certainly not the English text itself.

Keep keys to two or three levels. There’s a real ceiling here: five-level keys turn into noise you can’t search, and the namespacing stops earning its place the moment a key is harder to scan than the sentence it points at. Two or three levels groups cleanly and stays greppable. Five levels is a sign you’ve gone too deep.

The reason keys aren’t just the English text comes down to one split: keys are stable, and catalog values are mutable. You can rewrite the English copy of invoice.pastDue.title from “Invoice past due” to “This invoice is overdue” without touching a single component: you edit one line in en-US.json and ship. Renaming the key is a different kind of change. It’s a coordinated edit across the component and every language file at once, because all of them are pinned to that name. Stable identity in code, free-flowing text in the catalog. If the key were the English sentence, every copy tweak would become a rename across your entire catalog set, which is precisely the breakage you’re designing out.

This stability is also what makes the contract enforceable by tooling rather than vigilance. Because the keys are a known, fixed set, next-intl can generate a type from your en-US.json so that t('invoice.pastDue.title') type-checks and t('invoice.pastDue.ttile') is a compile error. A typo in a key 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 present in code but missing from a catalog, or stranded in a catalog with nothing referencing them. You won’t set either of those up today, but it’s worth knowing the payoff exists. The indirection of “key instead of text” is what lets the compiler guard the contract for you.

Try the drill below. Each blank is a real string that needs a key; pick the well-formed one. The wrong options are the four ways this commonly goes sideways, and learning to reject them on sight is the whole skill.

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 .

The slots are named ({count}, {name}), and that single choice is the hinge the whole discipline turns on. A named slot carries two things a position can’t: a meaning and a stable identity. When the translator opens the French entry and writes "Vous avez {count} messages non lus.", they’ve kept the slot’s name and moved it to a different place in the sentence. German can invert subject and verb and the slot follows along, still called {count}, still meaning “the unread count.” The translator sees {count} and knows exactly what it is and where it’s free to go.

The alternative, the one you want to be able to recognize and refuse, is the positional placeholder, inherited from C’s printf:

'You have %s unread %s';

Look at what the translator is handed here: two %s tokens, identified only by the order they appear. 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. And they can’t even tell what either one means. Is the first %s a count, a name, a date? The slot has been stripped of everything except its position, and position is the one property that doesn’t survive translation. This is also why concatenation fails: gluing fragments with + is positional by construction. The pieces are identified purely by the order you wrote them, and that order is English’s order, frozen.

Here is the cheapest possible proof: the same key, the same single {count} slot, three languages. Watch where the slot lands.

"count": "You have {count} unread messages"
The slot sits in the middle.

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 your code never noticed, which is the entire point. Named placeholders are the rule that makes this possible. ICU MessageFormat, in the next lesson, is the syntax that lives inside those slots once they need to do more than hold a value. The rule comes first because without it the syntax has nothing to stand on.

You’ve been seeing fragments of the catalog. Here’s the whole shape, because it’s concrete and there’s less to it than you might expect.

One JSON file per language. With messages/en-US.json, messages/fr-FR.json, and messages/de-DE.json, the entire catalog for a language lives in a single file, and you organize within it using nested objects. This is the default this stack uses, and it’s next-intl’s own recommendation. You may eventually read about splitting a language across multiple files merged at runtime. That’s a supported option for very large catalogs, but it’s not the norm and not where you start. One file per language, namespaced internally, is the shape to reach for.

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.

1 / 1

Read top to bottom and the path falls out: invoicepastDuetitle. That’s why the nesting and the dot-path are the same thing seen from two angles. The key is just the route through the objects.

Why JSON specifically? Because the catalog isn’t only read 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. YAML would work too, but JSON is the default here, and consistency is worth more than the marginal readability.

One last property, and it matters: the catalogs live in your repository, ship in your build, and are version-controlled alongside the code that uses them. They are not a remote service you call at runtime. This is the deeper reason 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 pull request, reviewed together. The translator’s edits flow back into those same files.

One string per key, and when reuse is actually fine

Section titled “One string per key, and when reuse is actually fine”

This is the most nuanced rule in the lesson, and the one beginners get wrong in both directions. The instinct, once you’ve grasped keys, is to deduplicate: forty buttons across the app all say “Save,” so surely they should all call t('common.save'). It feels like good engineering. It’s the canonical i18n bug.

The rule that prevents it: key reuse follows meaning, not English spelling. Reuse a key when two surfaces show genuinely the same message in the same role. Mint a new key when two surfaces happen to share an English word but mean different things, because a translator may need them to diverge in their language, and a shared key takes that choice away.

Walk the common.save trap concretely. In English, every one of those forty buttons reads “Save,” so one key seems to lose nothing. Now hand the catalog to a German translator. The Save on the settings page means save these preferences, which is Speichern. But one of those buttons is the final action in a checkout flow, and there “Save” really means place the order, which German would word entirely differently. A single shared key forces both surfaces to the same translation. The settings button reads fine, and the checkout button reads wrong. The bug is invisible to you, because you don’t read German, the English looks identical, and nothing fails. It surfaces only when a German-speaking customer hits checkout and the button says the wrong thing. Same English word, different meaning, different keys.

Now the flip side, because the lesson isn’t “never reuse,” which is the opposite mistake. Don’t mechanically split a key that genuinely is one message. If errors.unauthorized is shown the same way whether the user was blocked from an invoice or from a customer record, splitting it into errors.unauthorized.invoice and errors.unauthorized.customer is duplication for its own sake: now the translator edits the same sentence twice and the two can drift apart by accident. That’s the same problem pointing the other way.

So you need one test you can run every time, and it’s a single question about the future:

Could a translator legitimately want these two strings to differ in some language?

If yes, like the two “Save” buttons where German wants different verbs, they’re different keys, even when the English is identical today. If no, like the two “unauthorized” errors with the same message and same role, it’s one key, even though they live on different routes. The catalog is allowed to carry the same value under two different keys; that’s fine and expected. What you’re protecting is the translator’s freedom to make them diverge later without touching your code.

Sort the pairs below. Each is two surfaces that share an English string; decide whether they should share one key or get their own. Run the test on each one before you drag it.

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.

Same key Same message, same role
Separate keys Could diverge per language
Save on the settings page vs Save on the checkout’s place-order button
The Unauthorized error on the invoices route vs the same error on the customers route
Delete on a list row vs Delete on the close-your-account modal
Loading… shown while two different lists fetch
Open as a verb on a button vs Open as a status label on an invoice
Cancel on every dialog’s dismiss button across the app

The pairs that split are the ones where the same English word is doing different jobs. “Delete a row” is routine, “Delete your account” is an irreversible action a translator might phrase far more carefully, and “Open the document” (a verb) has nothing to do with “Open” as the invoice status (a label). The pairs that share are one message playing the same role in two places. Spelling is a coincidence; meaning is the contract.

Source-language completeness, and graceful fallback

Section titled “Source-language completeness, and graceful fallback”

Here’s the rule that lets a one-person team launch in a single language today and still keep the door open for ten languages later. It’s the mechanical heart of “discipline before feature.”

Every key must have a value in the source language. On this stack that source language is en-US, and note that this is the full locale tag, en-US, not a bare en, the same way you wrote full timezone and locale tags in the last chapter. A key referenced in code with no entry in the source catalog is a build error. You cannot ship a string that has nowhere to render in your own source language, and making that a build failure means you find out at your desk, not from a customer staring at a blank space.

Other languages are allowed to have gaps. When fr-FR.json is missing a key that en-US.json has, the runtime doesn’t crash and doesn’t render an empty box. It falls back to the source language and shows the English string. A French user mid-translation sees mostly French with the occasional English line, which is a perfectly shippable in-between state, not an outage.

And those gaps aren’t silent. A missing-key lookup surfaces to your logs, the dev console while you’re building and Sentry in production, so the translation pipeline has a concrete worklist of exactly what’s unfilled. Here is the shape this buys you: ship source-language-complete, and let translators fill the other languages asynchronously through their TMS, on their own schedule, without blocking a single one of your releases.

This is the chapter’s whole thesis made mechanical. A single-language launch already has en-US.json complete and every string flowing through t(). Adding French isn’t a refactor: it’s dropping a fr-FR.json next to the English one and letting translators fill it key by key. The shape was right from the first string, so the second language is a file, not a rewrite.

Strings with embedded markup: a look ahead

Section titled “Strings with embedded markup: a look ahead”

There’s one case the rule has to stretch to cover, and it’s worth previewing now because the wrong instinct is so natural; the full treatment comes a few lessons later. The case is a string with an inline element inside it: a link, a <strong>, an icon. Something like:

'By signing up, you agree to our <link>Terms</link>.';

The reach that looks reasonable is to split it into three keys, a prefix (“By signing up, you agree to our ”), a linkText (“Terms”), and a suffix (”.”), and concatenate them back together in JSX with the <Link> in the middle. But look at what you just did. That’s concatenation again, in disguise. Same frozen word order, same broken contract, only now the fragments are JSX instead of strings. Plenty of languages won’t want the link sitting where English puts it, and you’ve welded its position in place exactly as before.

The right reach keeps it as one key and lets the catalog own the whole sentence, link and all. 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 entire sentence and where the link sits inside it; you provide only the component for the tag. t.rich returns a ReactNode, real rendered JSX rather than a string. The full detail here, more tag patterns and the typing and the wiring, is a later lesson. The rule for today is just that the discipline doesn’t break when markup shows up: still one key, still the catalog’s sentence to own. And dangerouslySetInnerHTML is never the answer for translated content, because that’s how injected HTML becomes an XSS hole. t.rich exists precisely so you never reach for it.

Counts and gendered forms stay in the catalog

Section titled “Counts and gendered forms stay in the catalog”

Here is a shorter preview, aimed at the exact spot where the no-concatenation rule most often gets quietly reintroduced. Never branch on count === 1 in component code. The moment you write a plural ternary in a component, you’ve smuggled 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, and the component passes { count } and nothing more:

t('inbox.unread', { count });
// en-US.json: "unread": "{count, plural, ...}"

The same goes for gendered or role-based variants: the catalog is the surface that decides between them, never a switch in your component. The syntax inside that catalog value, the plural rules and the language categories, is the next lesson’s whole subject. The discipline you carry into it is this one: logic about how a string varies lives in the catalog, not the component. The component only ever passes the values.

A discipline is defined as much by its boundary as its rule. Over-applying “everything is a key” is its own waste, since you do not want to translate your debug logs. So before you run the audit, know what’s in scope and what isn’t.

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, at 3am, 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’s eyes as prose, and routing them through t() adds indirection for 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. But hold it together with its inverse, or you’ll start “fixing” console.error calls: any string that’s machine-facing should stay 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');
}

That’s the exact audit a reviewer runs on a pull request, and the exact grep you’ll run across a codebase before a launch: find the user-visible literals, route them through t(), and leave everything machine-facing untouched. The heading and the 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.

Translations cross the server/client boundary

Section titled “Translations cross the server/client boundary”

One worry remains, and it’s a fair one if you internalized the Server/Client Component split: which translation function do I call, and where? The reassuring answer is that the boundary you already know is unchanged. Translations cross it transparently, the same way data does.

The split maps cleanly onto the two call shapes you’ve already met. Server Components reach for getTranslations, which is async, because everything on the server can be. Client Components reach for useTranslations, a synchronous hook, because that’s what hooks are. Two functions, but one engine underneath and the same key in both. The boundary doesn’t change the contract; it only changes which door you walk through.

const t = await getTranslations('inbox');
return <p>{t('greeting', { name, count })}</p>;

Async. On the server, getTranslations is async: await it once, then call t as usual.

There’s a sensible default underneath this you should know now even though you won’t configure it for a few lessons: catalog data is server-only unless a Client Component asks for it. Your full set of translations doesn’t get shipped to every browser. The active language’s slice crosses to the client only where a Client Component actually consumes a key. So importing useTranslations in one component doesn’t bundle your entire catalog into the page. How that scoping is wired is a later lesson; for now the mental model is enough: same key everywhere, get* on the server, use* on the client, and the catalog stays server-side by default.

One shape carried this whole lesson, and it’s worth saying once more as a single sentence: the key lives in your code, the text lives in a per-language catalog, named slots let the catalog reorder freely, and nothing translatable is ever concatenated. Ship source-language-complete so a build can never go out with a string that has nowhere to render, and lean on per-key fallback so every other language fills in asynchronously without blocking you.

That’s the chapter’s thesis made concrete. The second language is one pull request, a fr-FR.json dropped beside the English one, precisely because you got the shape right from the very first line you wrote. You didn’t add i18n as a feature at the end; you wrote every string through the discipline from the start, and the discipline is what makes the feature cheap.

From here, the chapter fills in the pieces this lesson deliberately left as slots. The logic inside those {count, plural, ...} values is next. After that come the formatters that turn numbers, dates, and currencies into the right shape per language, how the active language is chosen per request, the library wiring that lands all of it in Next.js, and the SEO surface that routes the right language to the right user.