Knock Blog

I18N and ICU Message Format: Practical Usage in TypeScript with next-intl

12 min read3 viewsFrontend

Overview

This article covers ICU Message Format, a message syntax that lets you handle a wide range of i18n requirements effectively.

It also looks at how next-intl works with it, and how you can use it with react-i18next.

1. ICU (International Components for Unicode)

ICU Message Format is a message syntax that goes beyond simple string substitution in multilingual messages, letting you express numbers, dates, times, plural forms, and conditional branches in a locale-aware way.

For example, a simple message can be written like this.

{
  "hello": "안녕하세요, {name}님"
}

Here, {name} is replaced with a value passed in at runtime. (The Korean message above reads "Hello, {name}.") The sections below walk through the various formatting features ICU provides.

(ref: ICU User Guide — Formatting Messages)

1.1 ICU Argument Types at a Glance

The most commonly used argument types in ICU Message Format are as follows.

Type Purpose Example
Basic interpolation Insert dynamic values {name}
plural Branch messages by numeric count {count, plural, one {...} other {...}}
select Branch messages by string enum values {status, select, pending {...} other {...}}
selectordinal Ordinal number expressions {rank, selectordinal, one {...} other {...}}
date Date formatting {createdAt, date, medium}
time Time formatting {createdAt, time, short}
number Number, currency, and percent formatting {price, number, currency}

1.2 Interpolation

Interpolation is the most basic way to insert dynamic values into a message.

{
  "welcome": "Welcome, {name}."
}
t('welcome', {name: 'Nakhyeon'});

Result:

Welcome, Nakhyeon.

This is the most fundamental form of variable substitution in ICU Message Format.

1.3 Plural

plural is the ICU Message Format syntax for displaying different messages depending on a numeric value.

In languages like English, where the noun form changes between singular and plural, the wording depends on the number: 1 item, 2 items. With plural, you can branch the message according to each locale's plural rules.

{
  "itemCount": "{count, plural, one {# item} other {# items}}"
}

In the example above, one and other are plural categories. Which message gets used is determined by the count value passed in and the plural rules of the current locale.

t('itemCount', {count: 1});
// 1 item

t('itemCount', {count: 3});
// 3 items

Korean, on the other hand, makes little distinction between singular and plural, so simple interpolation is often enough. The message below (literally "{count} item(s)") reads naturally for any count.

{
  "itemCount": "상품 {count}개"
}

However, if you also support other languages like English, or the sentence itself needs to change depending on the number, using plural is the safer choice.

1.3.1 Exact Number Match

Beyond locale-based plural categories like one and other, plural also lets you match specific numbers exactly. This is known as exact number match.

{
  "cart": "{count, plural, =0 {Your cart is empty} one {You have # item} other {You have # items}}"
}

In the example above, =0 is used when count is exactly 0. # stands for the current count value.

t('cart', {count: 0});
// Your cart is empty

t('cart', {count: 1});
// You have 1 item

t('cart', {count: 5});
// You have 5 items

You can target specific numbers directly with =0, =1, =2, and so on. This is useful when you need to special-case a particular number — for example, "show a completely different sentence when the count is zero."

{
  "notification": "{count, plural, =0 {You have no notifications} =1 {You have 1 notification} other {You have # notifications}}"
}

=1 and one may look similar here, but they don't mean exactly the same thing.

  • =1 matches only when the numeric value is exactly 1.
  • one matches when the value falls into the one category under the current locale's plural rules.

In English, 1 almost always falls into one, so they behave similarly — but plural category rules can differ across locales. So if you want to special-case a specific number itself, use exact number matches like =0 and =1; if you want to defer to each language's singular/plural rules, use plural categories like one and other.

Here's another example.

{
  "searchResult": "{count, plural, =0 {No results found} other {# results found}}"
}

In this light, it's best to think of plural not merely as syntax for English singular/plural handling, but as a message-branching mechanism for choosing natural-sounding sentences based on a numeric value.

1.4 Select

select is the ICU Message Format syntax for displaying different messages depending on a specific string value.

It's useful when the copy changes based on an enum-like value, such as an order status, payment status, or user role. Like a JavaScript switch statement, it picks the appropriate message based on the value passed in.

It's a good idea to define an other case as well, to handle unexpected values.

Example:

{
  "orderStatus": "{status, select, pending {Your order is pending} paid {Payment completed} shipped {Your order is on its way} canceled {Your order has been canceled} other {Unable to determine order status}}"
}
t('orderStatus', {status: 'pending'});
// Your order is pending

t('orderStatus', {status: 'paid'});
// Payment completed

1.5 Date / Time / Number Formatting

Beyond string substitution and message branching, ICU Message Format also supports locale-aware date, time, and number formatting.

Dates, times, numbers, and currencies are written differently depending on the country and language. The same date might appear as 2026. 6. 8. in a Korean locale but as June 8, 2026 in an English locale. Numbers and currencies likewise vary by locale in their separators, currency symbols, and decimal notation.

In ICU Message Format, you can specify a value's format type right inside the message.

{
  "createdAt": "Created: {date, date, medium}",
  "updatedAt": "Last updated: {time, time, short}",
  "price": "Price: {price, number, currency}"
}

In the example above, {date, date, medium} converts the given date value into a medium-length date format for the current locale. {time, time, short} displays a time value in a short time format, and {price, number, currency} displays a numeric value as a currency.

With next-intl, you can use ICU formats inside messages, or use useFormatter() to format dates, times, and numbers directly in component code.

import {useFormatter} from 'next-intl';

export default function ProductPrice({price}: {price: number}) {
  const format = useFormatter();

  return (
    <p>
      {format.number(price, {
        style: 'currency',
        currency: 'KRW'
      })}
    </p>
  );
}

If the value needs to appear as part of a natural-language sentence within a message, in-message ICU formatting is the way to go; if a UI component just needs to display the value on its own, useFormatter() is often the better fit.

1.6 SelectOrdinal

selectordinal is the ICU Message Format syntax for handling ordinal number expressions.

In English, the suffix changes with the ordinal: 1st, 2nd, 3rd, 4th. In these cases, selectordinal lets you write messages that follow each locale's ordinal rules.

Korean typically expresses rank as {rank}위 (a single suffix regardless of the number), so this comes up less often — but it's worth knowing if you support English-speaking users.

1.7 Things to Watch Out for When Writing ICU Messages

ICU Message Format is a powerful syntax that can handle plural, select, date, number, and more within a single message. But precisely because it's so powerful, complex messages quickly become hard to read and prone to translation errors.

So when writing ICU messages, it pays to understand escaping, nested messages, and the argument type structure.

1.7.1 Escaping

In ICU Message Format, characters like {}, #, and ' can carry special meaning.

For example, {name} isn't plain text — it's interpreted as a variable supplied at runtime.

{
  "welcome": "Hello, {name}"
}

In the message above, {name} is replaced with the provided value at render time.

t('welcome', {name: 'Nakhyeon'});
// Hello, Nakhyeon

Likewise, inside a plural, # stands for the current numeric value.

{
  "itemCount": "{count, plural, one {# item} other {# items}}"
}
t('itemCount', {count: 3});
// 3 items

So if you need to display curly braces or the # character as literal text inside a message, be mindful of ICU's parsing rules. Escaping may be required especially when translation messages need to contain code samples, placeholder strings, or template syntax.

1.7.2 Nested Messages

ICU Message Format allows plural and select to be nested.

For example, you can branch first on the user's role, and then, within each branch, vary the message again by notification count.

{
  "notification": "{role, select, admin {{count, plural, =0 {No admin notifications} other {You have # admin notifications}}} user {{count, plural, =0 {No notifications} other {You have # notifications}}} other {Unable to determine notification status}}"
}

This works, but it should be used with caution in practice. The deeper the nesting, the more complex the message structure becomes, and the easier it is to hit runtime errors from a missing brace or a translation mistake.

Handle complex business conditions in code where possible, and keep translation messages structurally simple.

For example, you can split the message keys by role.

{
  "adminNotification": "{count, plural, =0 {No admin notifications} other {You have # admin notifications}}",
  "userNotification": "{count, plural, =0 {No notifications} other {You have # notifications}}"
}
const keyByRole = {
  admin: 'adminNotification',
  user: 'userNotification'
} as const;

t(keyByRole[role], {count});

This approach keeps message files readable and reduces the chance of mistakes during translation.

ICU Message Format can express a lot of conditions and formatting inside messages, but pushing all your business logic into messages is a bad idea. Messages should own the locale-dependent wording; complex conditional logic belongs in code. That split is much easier to maintain.

2. Key Features of next-intl

ICU Message Format is a syntax for expressing multilingual messages. next-intl, on the other hand, is a library for the Next.js environment that loads ICU messages and handles locale routing, Server Components, Client Components, React rich text rendering, and more.

So it helps to keep the two apart: plural, select, and date/time/number formatting belong to ICU Message Format, while t.rich(), locale routing, middleware, and Server Component support are features next-intl provides on top, tailored to Next.js and React.

2.1 Rich Text Rendering

Rich text rendering is a next-intl feature for when a translated message needs to contain UI elements like links, emphasis, highlights, or line breaks.

With t.rich(), next-intl lets you map tag-shaped placeholders inside a message to React components. This preserves natural sentence structure within a single translation message, without splitting the sentence across multiple translation keys.

{
  "terms": "I agree to the <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>."
}
t.rich('terms', {
  terms: (chunks) => <Link href="/terms">{chunks}</Link>,
  privacy: (chunks) => <Link href="/privacy">{chunks}</Link>
});

In the example above, <terms> and <privacy> aren't really HTML tags — they're placeholders to be replaced with React components. The actual href, className, event handlers, and so on should be managed in code, not written into the translation message.

That said, t.rich() isn't part of core ICU MessageFormat; it's a rich text API that next-intl provides for React rendering. You can combine it with plural and select, but to be precise, the feature comes from next-intl's React integration, not from ICU.

2.2 Locale Routing

next-intl supports building locale-based URLs on top of Next.js's routing structure.

For example, you can use a URL structure like this.

/ko/products
/en/products
/ja/products

With this structure, the appropriate message file is loaded based on the user's locale, and the page URL itself clearly signals the current language. For public pages or SEO-sensitive services, having the locale visible in the URL can be an advantage.

In the Next.js App Router, locale-specific routes are typically set up with a [locale] segment.

app/
  [locale]/
    layout.tsx
    page.tsx
messages/
  ko.json
  en.json

In this structure, message files like ko.json and en.json are loaded according to the locale value of the current route.

2.3 Server Component / Client Component Support

Because the Next.js App Router separates Server Components from Client Components, an i18n library has to respect that boundary too.

next-intl provides APIs for using translation messages in both Server Components and Client Components. In Client Components, you typically use useTranslations().

'use client';

import {useTranslations} from 'next-intl';

export default function Button() {
  const t = useTranslations('Button');

  return <button>{t('submit')}</button>;
}

In Server Components, messages can be fetched and rendered in the server environment. Since the translated strings are included when the initial HTML is generated, this approach benefits SEO and initial render.

So when working with next-intl, decide where to use which API based on the question: "does this translation belong in a Server Component or a Client Component?"

2.4 Metadata Translation

In Next.js, values like a page's title, description, and Open Graph metadata may also need to vary by locale.

For example, a Korean page might need metadata like this.

title: 상품 목록
description: 판매 중인 상품을 확인하세요.

The English page would differ.

title: Products
description: Browse available products.

With next-intl, you can use locale-appropriate messages not only in page content but also in metadata generation logic. If public pages, search visibility, or social sharing matter to your service, metadata translation should be within your i18n scope as well.

2.5 Message Structure and Namespaces

As your translation messages grow, rather than putting every key in one JSON file, it's better to split them into namespaces by screen or domain.

For example, you can organize them like this.

{
  "Home": {
    "title": "Home",
    "description": "Learn more about our service."
  },
  "Product": {
    "title": "Products",
    "price": "Price"
  },
  "Common": {
    "save": "Save",
    "cancel": "Cancel"
  }
}

In a component, you can pick just the namespace you need.

const t = useTranslations('Product');

t('title');
t('price');

Well-chosen namespaces reduce message key collisions and make it easier to see which translations a given screen actually uses.

3. i18n Libraries for React / Next.js

The React and Next.js ecosystems offer a variety of i18n libraries. They differ in message format, routing support, Server Component support, and how translation resources are managed.

In a Next.js project, i18n isn't just about substituting translated strings — you also need to consider locale routing, Server Components, metadata, middleware, and SEO. So while you can use a React-only i18n library as-is, a library with first-class Next.js integration is often the better fit.

3.1 next-intl

next-intl is a great i18n library choice for the Next.js environment. Built on ICU Message Format, it supports plural, select, and date/time/number formatting, and provides a structure designed to work with the App Router.

Example:

{
  "Home": {
    "title": "Home",
    "itemCount": "{count, plural, =0 {No products available} other {# products available}}"
  }
}
import {useTranslations} from 'next-intl';

export default function HomePage({count}: {count: number}) {
  const t = useTranslations('Home');

  return (
    <>
      <h1>{t('title')}</h1>
      <p>{t('itemCount', {count})}</p>
    </>
  );
}

The main features of next-intl are as follows.

Feature Description
ICU Message Format Supports plural, select, date, time, number
App Router support Works with the Next.js App Router structure
Server Component support Translation messages available at server render time
Client Component support Usable in client components via the useTranslations() hook
Rich Text Rendering Maps tags inside messages to React components via t.rich()
Locale Routing Supports locale-based routing such as /ko, /en

For a new project built on the Next.js App Router, next-intl should be your first candidate.

3.2 react-intl

react-intl is the React i18n library from the FormatJS ecosystem. It's one of the flagship libraries built around ICU Message Format, providing APIs like FormattedMessage and useIntl().

Example:

import {FormattedMessage} from 'react-intl';

export default function CartMessage({count}: {count: number}) {
  return (
    <FormattedMessage
      id="cart.itemCount"
      defaultMessage="{count, plural, =0 {No items} one {# item} other {# items}}"
      values={{count}}
    />
  );
}

react-intl is a good fit if you prefer a translation structure centered on ICU Message Format, or want to use FormatJS-based message extraction and management workflows.

However, Next.js concerns like locale routing, middleware, Server Components, and metadata translation may need to be designed yourself. Purely in terms of Next.js App Router integration, next-intl can be the simpler option.

3.3 react-i18next

react-i18next is the React binding library from the i18next ecosystem. It's widely used in the React world, with strong support for namespaces, fallbacks, lazy loading, and translation resource management.

The default i18next syntax differs from ICU Message Format.

{
  "welcome": "Hello {{name}}"
}
import {useTranslation} from 'react-i18next';

export default function Welcome() {
  const {t} = useTranslation();

  return <p>{t('welcome', {name: 'Nakhyeon'})}</p>;
}

If you want to use ICU Message Format, you can add the i18next-icu plugin.

import i18n from 'i18next';
import ICU from 'i18next-icu';
import {initReactI18next} from 'react-i18next';

i18n
  .use(ICU)
  .use(initReactI18next)
  .init({
    lng: 'en',
    resources: {
      en: {
        translation: {
          itemCount: '{count, plural, one {# item} other {# items}}'
        }
      }
    }
  });

react-i18next shines when you already have i18next-based translation resources, or need to share a translation setup across multiple frameworks such as React, Vite, and Next.js.

3.4 How to Choose

The selection criteria can be summarized as follows.

Scenario Recommended library
New Next.js App Router project next-intl
Centered on ICU Message Format and the FormatJS ecosystem react-intl
Existing i18next resources react-i18next
Need locale routing, Server Components, and metadata in Next.js next-intl
Sharing translation resources across React/Vite/Next.js react-i18next
Message extraction/compilation workflow matters react-intl or FormatJS tooling

If you're introducing i18n to a Next.js app for the first time, start by evaluating next-intl. If you need a translation system that works across the broader React ecosystem, react-i18next is also a solid choice. And if you want to lean heavily into an ICU Message Format-centric workflow, react-intl is worth considering.

Appendix: VSCode Extension

Installing the i18n Ally plugin makes i18n work much more convenient.

(Setup guide: Setting up the i18n Ally extension in VSCode (Korean))

Comments

Loading comments...