ICU MessageFormat is a standardized syntax for creating internationalized messages that adapt dynamically to linguistic rules like pluralization, gender agreement, and grammatical cases, solving problems that simple string interpolation cannot address while maintaining readability for both developers and translators. Developed by the International Components for Unicode (ICU) project and based on Unicode CLDR (Common Locale Data Repository) linguistic data, ICU MessageFormat provides a declarative way to express complex translation logic that would otherwise require extensive programming with conditional statements scattered throughout application code. The syntax handles diverse language requirements—from English's simple singular/plural distinction to Arabic's six plural forms, from gender-neutral languages like Turkish to heavily gendered Romance languages, from straightforward substitution to complex nested conditions—all within translator-editable string resources rather than hardcoded business logic. Modern i18n libraries including react-intl, FormatJS, vue-i18n, and i18next support ICU MessageFormat as their primary or optional message syntax, making it the de facto standard for sophisticated internationalization in 2026.
The Problem ICU MessageFormat Solves
Before exploring syntax details, understanding why ICU MessageFormat exists clarifies when and how to use it effectively.
The Limitations of Simple String Interpolation
Basic variable substitution fails for grammatically complex messages:
English (appears to work):
"You have {count} messages"
count = 1 → "You have 1 messages" ❌
count = 5 → "You have 5 messages" ✅
The naive fix uses conditionals in code:
JavaScript1function getMessage(count) { 2 if (count === 1) { 3 return `You have ${count} message`; 4 } else { 5 return `You have ${count} messages`; 6 } 7}
This seems fine until you translate to Polish, which has four plural forms:
- 1: "Masz 1 wiadomość"
- 2-4: "Masz 2 wiadomości"
- 5-21: "Masz 5 wiadomości"
- 22-24: "Masz 22 wiadomości"
- (Pattern repeats)
Or Russian with three forms, or Arabic with six. The conditional logic becomes unmaintainable and must be reimplemented for every language.
Gender Agreement Challenges
Romance languages require adjectives and past participles to agree with the subject's gender:
French:
- "{name} est connecté" (masculine)
- "{name} est connectée" (feminine)
Without ICU, you'd need:
JavaScript1if (user.gender === 'male') { 2 return `${user.name} est connecté`; 3} else { 4 return `${user.name} est connectée`; 5}
This couples translation logic with code and makes translator workflows cumbersome.
Combined Conditions
Real-world messages often combine multiple variables with interdependent grammatical rules:
"Sarah added 3 photos to her album" "John added 1 photo to his album" "They added 5 photos to their album"
You need gender agreement (his/her/their) AND pluralization (photo/photos) simultaneously. ICU handles this elegantly in a single translator-editable string.
Basic ICU MessageFormat Syntax
ICU messages consist of plain text with embedded argument placeholders.
Simple Arguments
The most basic form substitutes variables:
Hello, {name}!
Usage:
JavaScript1import { IntlProvider, FormattedMessage } from 'react-intl'; 2 3<FormattedMessage 4 id="greeting" 5 defaultMessage="Hello, {name}!" 6 values={{ name: 'Sarah' }} 7/> 8// Output: "Hello, Sarah!"
Multiple arguments:
{userName} sent {count} messages to {recipientName}
JavaScript1<FormattedMessage 2 id="message.sent" 3 defaultMessage="{userName} sent {count} messages to {recipientName}" 4 values={{ 5 userName: 'Alice', 6 count: 5, 7 recipientName: 'Bob' 8 }} 9/> 10// Output: "Alice sent 5 messages to Bob"
Argument Types
Arguments can have types that control formatting:
{variable, type, format}
Common types:
number: Numeric formattingdate: Date formattingtime: Time formattingplural: Plural selectionselect: Arbitrary selectionselectordinal: Ordinal selection (1st, 2nd, 3rd)
Plural Rules: The Core ICU Feature
Pluralization is ICU's most powerful capability, encoding CLDR linguistic rules for 200+ languages.
Basic Plural Syntax
{count, plural,
=0 {No items}
one {One item}
other {# items}
}
Breakdown:
count: The variable nameplural: The type=0,one,other: Plural categories{No items}: Message for that category#: Placeholder for the numeric value
Usage:
JavaScript1<FormattedMessage 2 id="cart.items" 3 defaultMessage="{count, plural, =0 {No items} one {One item} other {# items}}" 4 values={{ count: 0 }} 5/> 6// Output: "No items" 7 8<FormattedMessage 9 id="cart.items" 10 defaultMessage="{count, plural, =0 {No items} one {One item} other {# items}}" 11 values={{ count: 1 }} 12/> 13// Output: "One item" 14 15<FormattedMessage 16 id="cart.items" 17 defaultMessage="{count, plural, =0 {No items} one {One item} other {# items}}" 18 values={{ count: 5 }} 19/> 20// Output: "5 items"
CLDR Plural Categories
Different languages use different plural categories:
| Language | Categories | Rules |
|---|---|---|
| English | one, other | one: n=1 |
| French | one, other | one: n=0 or n=1 |
| Russian | one, few, many, other | Complex rules based on last digits |
| Polish | one, few, many, other | Even more complex rules |
| Arabic | zero, one, two, few, many, other | Six distinct forms |
| Chinese | other | No pluralization |
| Welsh | zero, one, two, few, many, other | Six forms with unique rules |
You don't need to memorize these rules—CLDR data is built into ICU libraries. Just provide translations for the categories relevant to each language.
Exact Matches with =n
Use =n for exact numeric values that should override category rules:
{count, plural,
=0 {No messages}
=1 {One message}
other {# messages}
}
This is useful for special cases like "No items" instead of "0 items", or promotional messages like:
{days, plural,
=7 {One week free trial!}
=14 {Two weeks free trial!}
=30 {One month free trial!}
other {# days free trial}
}
Complete Language Example: Russian
Russian requires three plural forms. Here's a real translation:
English:
{count, plural,
one {# item}
other {# items}
}
Russian:
{count, plural,
one {# предмет}
few {# предмета}
many {# предметов}
}
CLDR automatically applies correct rules:
- 1, 21, 31: "1 предмет" (one)
- 2, 3, 4, 22, 23, 24: "2 предмета" (few)
- 5-20, 25-30: "5 предметов" (many)
The # Symbol
# is replaced with the numeric value. It respects locale formatting:
JavaScript1// English locale 2{count: 1000, plural, other {# items}} → "1,000 items" 3 4// German locale 5{count: 1000, plural, other {# items}} → "1.000 items" 6 7// French locale 8{count: 1000, plural, other {# items}} → "1 000 items"
To display the number without formatting, repeat the variable name:
{count, plural, other {{count} items}}
Select Format: Gender and Arbitrary Selection
select handles any categorical variable, most commonly gender.
Basic Select Syntax
{gender, select,
male {He is online}
female {She is online}
other {They are online}
}
Usage:
JavaScript1<FormattedMessage 2 id="user.status" 3 defaultMessage="{gender, select, male {He is online} female {She is online} other {They are online}}" 4 values={{ gender: 'female' }} 5/> 6// Output: "She is online"
The other Category is Required
Unlike plural, which can infer categories, select requires an explicit other fallback:
{role, select,
admin {Administrator dashboard}
editor {Editor panel}
viewer {Viewer mode}
other {User dashboard}
}
If role is "guest" or any value not explicitly handled, it uses "User dashboard".
Real-World Example: Notification Messages
{senderGender, select,
male {{senderName} commented on his post}
female {{senderName} commented on her post}
other {{senderName} commented on their post}
}
This pattern is common in social apps, activity feeds, and notifications.
Beyond Gender: Any Categorical Variable
Select works for any categorical data:
{accountType, select,
free {Upgrade to Pro for more features}
pro {You have access to all features}
enterprise {Contact your account manager}
other {Unknown account type}
}
SelectOrdinal: 1st, 2nd, 3rd...
selectordinal formats ordinal numbers (ranking, positions) according to language rules.
Basic Selectordinal Syntax
{rank, selectordinal,
one {#st place}
two {#nd place}
few {#rd place}
other {#th place}
}
Usage:
JavaScript1<FormattedMessage 2 id="contest.rank" 3 defaultMessage="{rank, selectordinal, one {#st place} two {#nd place} few {#rd place} other {#th place}}" 4 values={{ rank: 1 }} 5/> 6// Output: "1st place" 7 8<FormattedMessage 9 id="contest.rank" 10 defaultMessage="{rank, selectordinal, one {#st place} two {#nd place} few {#rd place} other {#th place}}" 11 values={{ rank: 3 }} 12/> 13// Output: "3rd place"
Language-Specific Ordinal Rules
Different languages have different ordinal patterns:
English:
- one: 1st, 21st, 31st (ends in 1, except 11)
- two: 2nd, 22nd, 32nd (ends in 2, except 12)
- few: 3rd, 23rd, 33rd (ends in 3, except 13)
- other: 4th-20th, 24th-30th
French (simpler):
- one: 1er (premier)
- other: 2e, 3e, 4e... (all use 'e')
Chinese (even simpler):
- other: 第1, 第2, 第3... (all use same prefix)
Nested Messages: Combining Plural and Select
Real-world messages often require multiple conditions simultaneously.
Plural + Select Combination
{gender, select,
male {
{count, plural,
one {He uploaded # photo}
other {He uploaded # photos}
}
}
female {
{count, plural,
one {She uploaded # photo}
other {She uploaded # photos}
}
}
other {
{count, plural,
one {They uploaded # photo}
other {They uploaded # photos}
}
}
}
Usage:
JavaScript1<FormattedMessage 2 id="photo.upload" 3 defaultMessage="{gender, select, male {{count, plural, one {He uploaded # photo} other {He uploaded # photos}}} female {{count, plural, one {She uploaded # photo} other {She uploaded # photos}}} other {{count, plural, one {They uploaded # photo} other {They uploaded # photos}}}}" 4 values={{ gender: 'female', count: 3 }} 5/> 6// Output: "She uploaded 3 photos"
Complex Example: Shopping Cart
{itemCount, plural,
=0 {Your cart is empty}
other {
You have # {itemCount, plural, one {item} other {items}}.
{subtotal, number, currency}
}
}
Breakdown:
- If itemCount is 0: "Your cart is empty"
- Otherwise: "You have # item(s). $X.XX"
- Inner plural handles item/items
- Number formatting for currency
Number Formatting
ICU provides rich number formatting options using the Intl.NumberFormat spec.
Basic Number Format
{price, number}
This applies locale-specific number formatting:
JavaScript1// US locale 2{price: 1234.56, number} → "1,234.56" 3 4// German locale 5{price: 1234.56, number} → "1.234,56" 6 7// French locale 8{price: 1234.56, number} → "1 234,56"
Currency Format
{price, number, currency}
The currency code is passed as a separate variable:
JavaScript1<FormattedMessage 2 id="product.price" 3 defaultMessage="Price: {price, number, currency}" 4 values={{ price: 29.99, currency: 'USD' }} 5/> 6// Output (US locale): "Price: $29.99" 7// Output (EU locale): "Price: 29,99 $US"
Percent Format
{rate, number, percent}
JavaScript1<FormattedMessage 2 id="discount" 3 defaultMessage="Save {discount, number, percent}!" 4 values={{ discount: 0.25 }} 5/> 6// Output: "Save 25%!"
Custom Number Formats
Define custom formats in your intl configuration:
JavaScript1const intl = createIntl({ 2 locale: 'en-US', 3 messages, 4 formats: { 5 number: { 6 USD: { 7 style: 'currency', 8 currency: 'USD', 9 minimumFractionDigits: 2 10 }, 11 compact: { 12 notation: 'compact', 13 compactDisplay: 'short' 14 } 15 } 16 } 17});
Usage:
{price, number, USD} → "$29.99"
{followers, number, compact} → "1.2K"
Date and Time Formatting
Basic Date Format
{date, date, short}
{date, date, medium}
{date, date, long}
{date, date, full}
JavaScript1<FormattedMessage 2 id="order.date" 3 defaultMessage="Ordered on {orderDate, date, long}" 4 values={{ orderDate: new Date('2026-02-12') }} 5/> 6// Output (US): "Ordered on February 12, 2026" 7// Output (DE): "Ordered on 12. Februar 2026"
Time Format
{time, time, short}
{time, time, medium}
JavaScript1<FormattedMessage 2 id="delivery.time" 3 defaultMessage="Delivered at {deliveryTime, time, short}" 4 values={{ deliveryTime: new Date('2026-02-12T14:30:00') }} 5/> 6// Output (US): "Delivered at 2:30 PM" 7// Output (24h locale): "Delivered at 14:30"
Custom Date/Time Formats
JavaScript1const intl = createIntl({ 2 locale: 'en-US', 3 formats: { 4 date: { 5 monthYear: { 6 year: 'numeric', 7 month: 'long' 8 } 9 }, 10 time: { 11 hourMinute: { 12 hour: 'numeric', 13 minute: '2-digit' 14 } 15 } 16 } 17});
Usage:
{date, date, monthYear} → "February 2026"
{time, time, hourMinute} → "2:30"
Escaping Special Characters
ICU reserves certain characters ({, }, #) that require escaping in literal text.
Escaping Curly Braces
To display literal { or }:
Use '{' and '}' like this: '{example}'
Output: "Use { and } like this: {example}"
For single quotes within messages:
It's a nice day → No escaping needed
'It''s a nice day' → Output: "It's a nice day" (doubled single quotes)
Escaping the # Symbol
Inside plural/selectordinal, # is special. To show literal #:
{count, plural,
one {Item '#'1}
other {Items '#'{count}}
}
Better approach: Avoid # in literals, use different symbols.
Common Mistakes and Debugging
Mistake 1: Missing other Category
{count, plural,
one {One item}
// ❌ Missing 'other' category
}
Error: "Missing required 'other' case"
Fix: Always include other:
{count, plural,
one {One item}
other {# items}
}
Mistake 2: Using Plural Categories for Wrong Language
// English translation (correct)
{count, plural, one {# item} other {# items}}
// Polish translation (wrong - missing categories)
{count, plural, one {# przedmiot} other {# przedmioty}}
Polish needs few and many categories:
{count, plural,
one {# przedmiot}
few {# przedmiot}
many {# przedmiotów}
other {# przedmiotów}
}
Mistake 3: Mismatched Argument Names
JavaScript1// Message 2"{userName} sent a message" 3 4// Usage (typo in variable name) 5<FormattedMessage 6 id="message" 7 values={{ username: 'Alice' }} // ❌ Should be userName 8/> 9// Output: " sent a message" (userName is undefined)
Fix: Match variable names exactly, or use TypeScript for type safety.
Mistake 4: Over-Nesting
{a, select,
x {{b, select,
y {{c, plural,
one {{d, select, ...}}
other {...}
}}
other {...}
}}
other {...}
}
This is technically valid but unmaintainable. Refactor into multiple messages or simplify logic.
Library Support and Integration
react-intl (FormatJS)
Terminalnpm install react-intl
TSX1import { IntlProvider, FormattedMessage } from 'react-intl'; 2 3const messages = { 4 en: { 5 'cart.items': '{count, plural, =0 {No items} one {One item} other {# items}}' 6 } 7}; 8 9function App() { 10 return ( 11 <IntlProvider locale="en" messages={messages.en}> 12 <Cart /> 13 </IntlProvider> 14 ); 15} 16 17function Cart() { 18 const itemCount = 5; 19 20 return ( 21 <div> 22 <FormattedMessage id="cart.items" values={{ count: itemCount }} /> 23 </div> 24 ); 25}
i18next
i18next requires ICU plugin:
Terminalnpm install i18next i18next-icu
JavaScript1import i18next from 'i18next'; 2import ICU from 'i18next-icu'; 3 4i18next 5 .use(ICU) 6 .init({ 7 resources: { 8 en: { 9 translation: { 10 'cart.items': '{count, plural, =0 {No items} one {One item} other {# items}}' 11 } 12 } 13 } 14 }); 15 16i18next.t('cart.items', { count: 5 }); // "5 items"
vue-i18n
Vue 3 + vue-i18n v9+ supports ICU via @intlify/message-compiler:
Terminalnpm install vue-i18n@next
JavaScript1import { createI18n } from 'vue-i18n'; 2 3const i18n = createI18n({ 4 locale: 'en', 5 messages: { 6 en: { 7 cart: { 8 items: '{count, plural, =0 {No items} one {One item} other {# items}}' 9 } 10 } 11 } 12}); 13 14// In component 15{{ $t('cart.items', { count: 5 }) }}
Angular (Internationalization with @angular/localize)
Angular doesn't natively support ICU in the same way, but provides similar functionality via i18n attributes:
HTML1<span i18n="@@cart.items"> 2 {count, plural, 3 =0 {No items} 4 one {One item} 5 other {{{count}} items} 6 } 7</span>
IntlPull's ICU Editor
IntlPull provides a visual ICU MessageFormat editor that helps translators work with complex ICU syntax without breaking message structure. Features include:
- Syntax highlighting and validation
- Visual preview with sample data
- Category suggestions based on target language CLDR rules
- Automatic format preservation during translation
- Real-time error detection for missing categories
Try it at intlpull.com/tools/icu-editor.
Advanced Patterns and Best Practices
Pattern 1: Extracting Common Fragments
For repeated phrases, use variables:
JavaScript1const fragments = { 2 userName: <b>{userName}</b> 3}; 4 5<FormattedMessage 6 id="notification" 7 defaultMessage="{userName} commented on your post" 8 values={fragments} 9/>
Pattern 2: Parameterizing Units
Don't hardcode units in translations:
// ❌ Bad
{count, plural, one {# kilometer} other {# kilometers}}
// ✅ Good
{count, plural, one {# {unit}} other {# {unit}}}
Pass unit as variable for flexibility (km vs. mi).
Pattern 3: Accessibility Considerations
Provide alternative text for screen readers:
TSX1<FormattedMessage 2 id="button.delete" 3 defaultMessage="Delete {count, plural, one {# item} other {# items}}" 4 values={{ count }} 5> 6 {(text) => ( 7 <button aria-label={text}> 8 <TrashIcon /> 9 <span>{text}</span> 10 </button> 11 )} 12</FormattedMessage>
Pattern 4: Performance Optimization
Memoize complex ICU messages:
TSX1import { useMemo } from 'react'; 2import { useIntl } from 'react-intl'; 3 4function ExpensiveComponent({ items }) { 5 const intl = useIntl(); 6 7 const message = useMemo( 8 () => intl.formatMessage( 9 { id: 'items.summary' }, 10 { count: items.length } 11 ), 12 [intl, items.length] 13 ); 14 15 return <div>{message}</div>; 16}
Testing ICU Messages
Unit Testing
TypeScript1import { createIntl, createIntlCache } from 'react-intl'; 2 3describe('ICU Messages', () => { 4 const cache = createIntlCache(); 5 const intl = createIntl({ 6 locale: 'en', 7 messages: { 8 'cart.items': '{count, plural, =0 {No items} one {One item} other {# items}}' 9 } 10 }, cache); 11 12 it('handles zero items', () => { 13 expect(intl.formatMessage({ id: 'cart.items' }, { count: 0 })) 14 .toBe('No items'); 15 }); 16 17 it('handles one item', () => { 18 expect(intl.formatMessage({ id: 'cart.items' }, { count: 1 })) 19 .toBe('One item'); 20 }); 21 22 it('handles multiple items', () => { 23 expect(intl.formatMessage({ id: 'cart.items' }, { count: 5 })) 24 .toBe('5 items'); 25 }); 26});
Snapshot Testing
TypeScript1import { render } from '@testing-library/react'; 2import { IntlProvider } from 'react-intl'; 3 4it('renders cart summary correctly', () => { 5 const { container } = render( 6 <IntlProvider locale="en" messages={messages}> 7 <CartSummary items={[...]} /> 8 </IntlProvider> 9 ); 10 11 expect(container).toMatchSnapshot(); 12});
Cross-Language Testing
Test that all languages have valid ICU syntax:
TypeScript1import IntlMessageFormat from 'intl-messageformat'; 2 3describe('Translation Validity', () => { 4 const languages = ['en', 'es', 'de', 'fr']; 5 6 languages.forEach(lang => { 7 it(`all ${lang} messages compile`, () => { 8 Object.entries(messages[lang]).forEach(([key, message]) => { 9 expect(() => { 10 new IntlMessageFormat(message, lang); 11 }).not.toThrow(); 12 }); 13 }); 14 }); 15});
FAQ
When should I use ICU MessageFormat vs. simple interpolation?
Use simple interpolation for basic variable substitution without grammatical changes. Use ICU for any message involving plurals, gender agreement, or conditional text based on variables. If you're unsure, start with ICU—it's more flexible and prevents future refactoring.
Do all i18n libraries support ICU MessageFormat?
Most modern libraries support ICU, but some require plugins. react-intl and FormatJS support it natively. i18next requires the i18next-icu plugin. vue-i18n v9+ supports it. Check your library's documentation for ICU support details.
How do I validate ICU syntax before deployment?
Use linters like eslint-plugin-formatjs that parse and validate ICU messages during development. IntlPull's platform includes automatic ICU validation that flags syntax errors, missing plural categories, and format mismatches before translations go live.
Can I use ICU MessageFormat for server-side rendering?
Yes. Libraries like @formatjs/intl work in Node.js environments. Ensure you polyfill Intl APIs for older Node versions (pre-13), or use full-icu build. Most modern deployments (Node 14+) include full Intl support by default.
What's the performance impact of ICU MessageFormat?
ICU message compilation happens once and is cached, so runtime overhead is minimal. Complex nested messages add microseconds of processing time—negligible compared to network requests and rendering. For extremely high-traffic applications, pre-compile messages during build time using babel-plugin-formatjs.
How do I handle ICU messages in translation workflows?
Modern translation management systems (IntlPull, Lokalise, Phrase) preserve ICU syntax during translation and provide visual editors for translators. Educate translators about placeholder preservation and provide context for variables. Use ICU-aware CAT tools that show variables as non-translatable segments.
Can I mix ICU and non-ICU messages in the same app?
Yes, but it's not recommended. Mixing syntaxes creates confusion for developers and translators. Choose one approach and stick to it. If migrating from simple interpolation to ICU, do it gradually file-by-file, but maintain consistency within each file.
