ICU MessageFormat: plurals, select, gendered forms
ICU MessageFormat, the Unicode syntax that lets a single translation string carry plural, ordinal, and gendered grammar so the catalog, not your component, handles per-language choice.
The last lesson left a loose thread. The inbox line read You have {count} unread messages, and {count} sat there as a plain named slot. That works when the count is always plural, but the moment it can be 1, the grammar breaks: “You have 1 unread messages” is wrong, and the same problem affects every count-shaped string in your app.
Here is the code you would reach for today, and it feels disciplined. The catalog gave you the sentence, so surely the choice between singular and plural is yours to make in code:
// The catalog owns the sentence — but the plural is ours to handle, right?const noun = count === 1 ? t('inbox.message') : t('inbox.messages');return <p>{t('inbox.unread', { count, noun })}</p>;This is the same mistake the last lesson warned against for word order, returning in a different disguise. There you learned not to concatenate fragments, because translators reorder sentences; here the ternary hard-codes English’s idea of plural, one form for 1 and one for everything else, straight back into the component the rule just cleaned up. English has two plural forms. Russian has four. Arabic has six. Welsh has six. Chinese has one. A ternary cannot express any of that, and writing one ternary per language would turn your component into a grammar textbook.
The fix has the same shape as last lesson: push the logic out of JavaScript and into the catalog string, where per-language rules already live. The string carries the branching, the runtime picks the right branch for the current locale, and your call collapses back to a single value:
return <p>{t('inbox.unread', { count })}</p>;That string lives in the catalog, and its syntax is ICU MessageFormat . By the end of this lesson you will read and author it for three kinds of choice: counts, ordinals, and free variants like gender. That includes the nested case where a notification needs both a person and a count. You will also know the four ways these strings break, three of which fail silently.
What ICU MessageFormat is
Section titled “What ICU MessageFormat is”Last lesson, the three-party contract had a clean division: you hold the key, the catalog holds the text, the translator owns the catalog. The text was always a flat sentence with named slots. ICU MessageFormat keeps that contract exactly, with the same key and the same translator, but gives the catalog value a richer vocabulary. The value stops being a flat sentence and becomes a tiny template the runtime interprets.
Here is the full shape for the inbox line:
{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}When that string renders, the runtime does four things. It reads the variable, count, from the object you passed to t(). It looks up which plural category that number falls into for the current locale. It picks the matching branch. And it substitutes the # token with the locale-formatted number. None of that logic is in your component; all of it is in the string the translator can edit.
The “ICU” in the name stands for International Components for Unicode , the reference implementation everyone copied. The rules it consults come from CLDR , the dataset behind every locale-aware feature in the platform. next-intl, the library you met by name last lesson, ships an ICU MessageFormat parser, so you write these strings in the catalog and the library applies them.
ICU can express five kinds of choice. Naming the whole set up front gives you the map, even though this lesson only teaches part of it:
pluralfor cardinal counts (“3 messages”).selectordinalfor ordinals (“3rd place”).selectfor free string variants (gender, role, notification type).- Inline
numberanddateformatting, for currencies, dates, and percentages.
This lesson teaches the first three in depth. The last two, formatting a number or a date inside a message, exist, but the experienced choice is not to use them. When a string needs a formatted currency or a localized date, you make the formatter call explicitly at the seam and pass the result in, rather than burying a format skeleton inside the ICU string. That formatter family, Intl.NumberFormat, Intl.DateTimeFormat, and friends, is the whole subject of the next lesson. Note the name, set it aside, and don’t go looking for inline number syntax here.
Anatomy of a plural message
Section titled “Anatomy of a plural message”The plural form does the most work, so it is worth taking apart carefully once. Here is the inbox message again, broken into its parts.
{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}The variable. It names which value drives the choice, and it matches the key in the object you pass: t('inbox.unread', { count }). This is the one and only thread back to your component: change the count, and you change the branch.
{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}The selector keyword. plural means treat this value as a cardinal number and pick a branch using the locale’s plural categories. Two other keywords, selectordinal and select, come later; plural is the one you will reach for most.
{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}The branch table: a list of selector {message} arms. Two kinds of selector appear here. Exact matches like =0 are tried first, and CLDR keywords like one and other are tried after. other is mandatory; it is the catch-all when nothing else matches.
{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}The # token. Inside a branch, # is the count rendered as a locale-aware number, with grouping and decimals applied. Note that it is #, not {count}. Mixing those two up is the most common authoring bug, and it is the whole next section.
Step back and notice what your component looks like next to all of that: nothing changed. The call is still t('inbox.unread', { count }). Every decision about how the string varies with the count lives in the catalog, exactly where the last lesson put decisions about word order. The component passes data; the catalog holds grammar.
# is the formatted count, {count} is a bug
Section titled “# is the formatted count, {count} is a bug”The distinction between # and {count} deserves its own section, because getting it wrong fails silently and survives code review.
Inside a plural branch, # is a special token: it stands for the selector value, count, formatted as a locale-aware number. You don’t name the variable again. # already knows it is the count, and it knows the locale, so 12345 renders as 12,345 in en-US and 12 345 in fr-FR for free.
Write {count} instead and two things go wrong at once. {count} re-interpolates the raw variable as an ordinary named placeholder, so you get the number, but unformatted, with no grouping. And because you have left a {count} where the # was meant to go, the natural mistake is to print the variable twice: a branch reading {count} {count} messages renders “5 5 messages.” The output is wrong, but it is not an error. Nothing throws, the page renders, and a reviewer skimming the catalog sees a plausible-looking string. The only defense is knowing the rule.
Predict what the following program prints. One message uses # correctly; a sibling message makes the {count} mistake.
Predict what this program prints, then press Check.
The locale is en-US. format(message, values) runs an ICU message the way t() would.
const count = 1234;
// Correct: `#` renders the formatted count.const right = format('{count, plural, other {# unread messages}}', { count });
// Bug: `{count}` re-prints the raw variable, and it's doubled.const wrong = format('{count, plural, other {{count} {count} unread messages}}', { count });
console.log(right);console.log(wrong);# is the formatted selector value — it applies the locale’s number grouping, so 1234 renders as 1,234. {count} re-prints the raw variable with no formatting, and here it appears twice, so the count lands twice while #’s job goes undone.One language has two forms, most don’t
Section titled “One language has two forms, most don’t”Look back at the inbox message and you will see it carries three branches: =0, one, other. The one and other are not arbitrary names. They are plural categories , and English happens to use exactly two of them. The two-form world feels universal because it is the only one most of us grew up with, but it is not. CLDR declares, per language, which categories exist and which numbers fall into each, and the variation is dramatic.
The runtime handles all of it. You write one message shape for your source language, the translator supplies the branch set their language needs, and CLDR decides which branch a given number lands in. You never enumerate languages or write a rule. The tabs below show the same inbox message across four languages; watch the branch set change while your call stays identical.
{count, plural, one {# unread message} other {# unread messages}}2 categories. one (just 1) and other (everything else). This is the world that misleads: it is an English accident, not a universal. (Branch bodies stay in English here; the teaching point is the set of branches, not the translation.)
{count, plural, one {# message non lu} other {# messages non lus}}2 categories day to day, but French folds 0 into one: it says “0 message non lu”, singular, where English says “0 messages”, plural. (French has a many category too, but it only fires for large numbers in the millions.)
{count, plural, one {# сообщение} few {# сообщения} many {# сообщений} other {# сообщения}}4 categories every day, chosen by the last digit: numbers ending in 1 (but not 11) → one; ending in 2 through 4 (but not 12 through 14) → few; the rest → many.
{count, plural, zero {…} one {…} two {…} few {…} many {…} other {…}}All 6 CLDR categories: zero one two few many other. Arabic inflects differently for 0, 1, 2, a few, many, and the general case.
The point to hold onto is that plural categories are a property of the language, not a universal you can hard-code. And across all four tabs, your component never changed: it called t('inbox.unread', { count }) every time. Only the catalog branch set differs, and that is the translator’s surface, not yours. These are cardinal numbers; the next keyword, for positions, branches differently.
You don’t memorize these tables. When you need to know a language’s categories, you look them up. The canonical reference is the Unicode CLDR chart:
The per-language table of plural categories and the numbers that map to each. Reference, not memorization.
Intl.PluralRules is the engine
Section titled “Intl.PluralRules is the engine”Seeing the machinery under the message keeps the per-language behavior from feeling like magic. The thing that maps a number to a plural category is a native browser API: Intl.PluralRules. Every ICU MessageFormat implementation calls into it (or a polyfill) to choose the branch.
new Intl.PluralRules('en-US').select(1); // 'one'new Intl.PluralRules('en-US').select(2); // 'other'new Intl.PluralRules('ru-RU').select(5); // 'many'You will rarely call this yourself, since ICU does it for you when it picks a branch. But running it once proves the categories are real and shipping in your runtime today, not invented by a library. (Pass { type: 'ordinal' } to switch it to ordinal categories; that is the engine behind the next section.)
See it for yourself. Implement categoryFor so it returns the plural category for a number in a given locale, then watch Russian hand back few and many from one line of standard JavaScript.
Implement categoryFor(locale, n) so it returns the CLDR plural category for the number — a single Intl.PluralRules call does it. These categories ship in your runtime; this just reads them.
Reference solution
function categoryFor(locale, n) { return new Intl.PluralRules(locale).select(n);}Exact-match overrides for product copy
Section titled “Exact-match overrides for product copy”Return to that =0 in the inbox message. CLDR categories cover grammar, but sometimes you want different wording for a specific number, and that is a product decision, not a grammatical one. “No unread messages” reads better than “0 unread messages.” In some narrative copy, “You” reads better than “1 person.” ICU’s exact matches, =0, =1, and =2, handle this. They are literal value matches, tried before the category keywords, so =0 wins for exactly zero and one and other handle the rest. The convention is to list exact matches first, then keywords, then other.
There is a sharp rule hiding here, and it is the difference between code that works in English and code that works everywhere: reach for the CLDR keyword by default, and use an exact match only when the wording itself changes. It is tempting to write =1 instead of one, since in English one only ever matches the number 1, so they look interchangeable. They are not. In some languages the one category covers more than the literal 1: numbers ending in 1, like 21 and 31, can land in one. Write =1 and you catch only the literal 1; every other number that should have used the singular form silently falls through to other. It passes every test you would write in English and breaks the moment a translator localizes it.
{count, plural, =1 {# unread message} other {# unread messages}}The trap. This works perfectly in English, where one only ever means 1. But =1 matches only the literal 1, so in a language whose one category also covers 21, 31, and so on, those numbers silently drop to other and get the wrong form.
{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}The robust shape. one and other carry the grammar, correct in every language, and =0 is a deliberate wording override for the empty state. Each translator decides whether their language wants the same =0 carve-out.
The override lives in the source-locale catalog as a product decision. Translators inherit the structure and decide, per language, whether the same carve-out makes sense for them.
Ordinals need selectordinal
Section titled “Ordinals need selectordinal”The inbox count is a cardinal: how many. Ranks and positions are a different kind of number, which place, and they branch on a different set of rules. “1st, 2nd, 3rd, 4th” is the canonical English example, and it uses the second selector keyword: selectordinal.
{rank, selectordinal, one {#st place} two {#nd place} few {#rd place} other {#th place}}selectordinal works exactly like plural, with the same # token and the same mandatory other rule, but it consults CLDR’s ordinal rules instead of cardinal ones. The two rule sets disagree, which is the counter-intuitive part. The same numbers land in different categories depending on which kind you ask for:
n: 1 2 3 4cardinal: one other other other → 1, 2, 3, 4 (no suffix logic)ordinal: one two few other → 1st, 2nd, 3rd, 4thSo 2 is cardinal-other but ordinal-two, and 3 is ordinal-few. That is why you write “2 messages” but “2nd place.” Other languages differ entirely: German writes “1.”, “2.”, and Japanese uses 番. Again, the translator picks the branches their language needs while you write one t('leaderboard.rank', { rank }). Reach for selectordinal for leaderboards, rankings, positions, and “your 3rd invoice.” These are ordinal numbers.
select for variants the data already carries
Section titled “select for variants the data already carries”The third keyword changes what you branch on. plural and selectordinal branch on a number, using categories the runtime computes from CLDR. select branches on a string, using literal matches you define, and like the other two it needs a mandatory other fallback. This is the keyword for variants like gender, notification type, or role.
Here is the new running example for the rest of the lesson, a “liked your post” notification that varies by the actor’s gender:
{gender, select, male {He liked your post} female {She liked your post} other {They liked your post}}The selector is a string, 'male', 'female', or anything else, and each branch is a literal match against it. other catches everything you didn’t list, which makes it the right home for “prefer not to say,” unknown, or any value the data doesn’t pin down. The same shape works for a notification type (invoice, payment, other) or an organization role (admin, member, other).
Here is the rule that separates a correct select from a bug: select renders a distinction the data already carries, never one you infer. The classic mistake is reaching for the user’s name to guess gender. Don’t. The selector value comes from a column your database actually stores. If your data doesn’t record gender, then the message has only an other branch and “They liked your post,” and that is not a gap to paper over; it is the correct, complete answer. Many English source strings collapse to gender-neutral writing for exactly this reason. A German catalog might carry all three branches because German pronouns and nouns inflect, but the data feeding the selector is the same either way.
Gendered counts: nesting select over plural
Section titled “Gendered counts: nesting select over plural”Real notifications combine both dimensions. “He has 3 new messages” needs a person and a count, which means a select and a plural. ICU composes them by nesting: a select whose every branch contains a complete plural message.
{gender, select, male {{count, plural, one {He has # new message} other {He has # new messages}}} female {{count, plural, one {She has # new message} other {She has # new messages}}} other {{count, plural, one {They have # new message} other {They have # new messages}}}}The outer selector: a select on gender, with the three string branches you already know, male, female, and other. Nothing new yet; this is the select from the last section.
{gender, select, male {{count, plural, one {He has # new message} other {He has # new messages}}} female {{count, plural, one {She has # new message} other {She has # new messages}}} other {{count, plural, one {They have # new message} other {They have # new messages}}}}Each branch’s body is itself a complete message, here a full plural on count. The male arm is not a string; it is a whole nested ICU message that runs only when gender is 'male'.
{gender, select, male {{count, plural, one {He has # new message} other {He has # new messages}}} female {{count, plural, one {She has # new message} other {She has # new messages}}} other {{count, plural, one {They have # new message} other {They have # new messages}}}}Inside, the familiar plural branch table: one and other, picked by the count’s CLDR category. The outer select chose the pronoun; the inner plural chose the noun form.
{gender, select, male {{count, plural, one {He has # new message} other {He has # new messages}}} female {{count, plural, one {She has # new message} other {She has # new messages}}} other {{count, plural, one {They have # new message} other {They have # new messages}}}}Two levels deep, # still means the formatted count. The nesting changed nothing about the token; it is the count, locale-formatted, exactly as before.
When you nest, put the lower-cardinality dimension on the outside. Here gender has three values and plural multiplies inside each, so select (gender) wraps plural (count). This is a readability heuristic, not a hard law, and the real test is whether the translator can follow the structure, but putting the coarser cut on the outside usually reads best. And your call stays as flat as ever, two values and one key:
return <p>{t('notification.newMessages', { gender, count })}</p>;This is the recurring point of the whole lesson, and the nested case makes it clear: even two levels deep, the translator only ever edits the text inside the innermost arms, “He has # new message,” “She has # new messages.” They never touch the structure, never see a brace they did not write, and never read a line of JavaScript. The ICU syntax is the boundary; everything inside a branch is just text.
Authoring an ICU message: the rules that bite
Section titled “Authoring an ICU message: the rules that bite”You can now read these strings. Authoring them correctly is its own skill, because three of the four common mistakes are silent. Here are the rules, each tied to the failure it prevents:
otheris mandatory. Leave it out and the parser throws when it loads the message, a real and loud error. This is the one mistake the tooling catches for you; the other three don’t throw.- Use
#for the count inside plural branches, never{count}. Get it wrong and you ship “5 5 messages” with no error in sight. - CLDR keyword by default, exact match only for wording overrides.
=1in place ofoneworks in English and quietly drops 21, 31, and so on to the wrong branch in other languages. selectneeds the data to carry the value. Branch on a column your database stores; never infer the distinction from a name.
Two smaller hazards round out the set. ICU treats {, }, and ' as syntax, so a literal apostrophe or brace inside your text has to be escaped. JSON editors don’t know ICU’s escaping rules, so the safe move is to validate a catalog by running it through the actual runtime, not by reading it. And while every serious library handles all three keywords, some that advertise “ICU support” skip selectordinal or select. next-intl covers all three, but if you ever swap libraries, confirm against the runtime rather than the marketing. (If you ever let content editors write these strings in a database, validate the ICU syntax at write time, but database-stored messages are a separate topic.)
Now complete a message yourself. Each blank has exactly one form that is correct across all languages, not just English.
Complete the ICU message. Each blank has exactly one form that's correct across every language — not just English. Pick the right option from each dropdown, then press Check.
{gender, ___, male {{count, ___, one {He has ___ new message} ___ {He has # new messages}}} female {...} other {...}}MessageFormat 2, in one breath
Section titled “MessageFormat 2, in one breath”There is one more term to recognize in the wild. Unicode is finalizing MessageFormat 2 (MF2) , a cleaner syntax with explicit declarations and built-in formatter integration. As of 2026, most i18n libraries, next-intl included, still target MF1, the syntax you just learned. When the ecosystem moves to MF2 the migration is mechanical, so nothing here is wasted effort. You don’t need the MF2 syntax today; you only need to recognize the name when it comes up.
Count-shaped and person-variant strings stay single keys; the logic about how they vary moves into the catalog as ICU MessageFormat. plural and selectordinal branch on a number using the CLDR categories the runtime computes; select branches on a string the data already carries. # is the formatted count, other is always required, and the translator only ever edits the text inside the branches. Your component call never carries any of it. It stays flat: t(key, { count }), t(key, { gender, count }), with no ternary and no inference.
There is one thing ICU deliberately handed off: formatting a number or a date inside a message. The next lesson picks that up directly with Intl.NumberFormat for currencies and percentages, Intl.DateTimeFormat for the Temporal values from earlier in the chapter, and Intl.RelativeTimeFormat for “3 days ago,” the native formatter family that powers every locale-aware render in your app.
External resources
Section titled “External resources”Edit an ICU message and watch live output across English, German, Japanese, and Arabic — the fastest way to see plural categories and # in action.
How next-intl loads catalogs and the ICU plural / select / selectordinal forms it supports in this stack.
A worked reference for plural, select, selectordinal, and the # token, with an inline editor on every example.
The canonical ICU message syntax guide, including the apostrophe and brace escaping rules.