Examples

Recipes — plurals, dates, numbers, rich tags, Server Components.

ICU plurals

ICU MessageFormat handles plural rules per locale. Define the plural clauses in the source string; the SDK picks the right branch via Intl.PluralRules.

// messages/en.json
{
  "cart": {
    "items": "{count, plural, =0 {empty} one {# item} other {# items}}"
  }
}
const t = useTranslations();

t('cart.items', { count: 0 });   // → "empty"
t('cart.items', { count: 1 });   // → "1 item"
t('cart.items', { count: 3 });   // → "3 items"

The =0 clause matches exactly; the one / other clauses use the locale's plural rules. For Russian, Arabic, or other languages with multiple plural forms, add few, many, and two clauses as needed.

The # token is the count value (after subtracting any offset). Use it to embed the number in the rendered string.

Date and number formatting

ICU supports inline formatting clauses inside translations.

{
  "order": {
    "placed": "Placed on {when, date, long}",
    "total": "Total: {amount, number, ::currency/USD}"
  }
}
t('order.placed', { when: order.createdAt });
// → "Placed on March 15, 2026"

t('order.total', { amount: 1234.5 });
// → "Total: $1,234.50"

Use the standard ICU date / number skeleton vocabulary: short, medium, long, full for date/time; percent, integer, or the new ::skeleton syntax for numbers.

Rich tag interpolation

You can embed React components in the middle of translated strings using tag syntax: <link>terms</link>. The SDK calls a function you provide to render the chunk between the tags.

{
  "terms": {
    "agreement": "By signing up you agree to our <link>terms of service</link>."
  }
}
const t = useTranslations();

return (
  <p>
    {t('terms.agreement', {
      link: (chunks) => <a href="/terms">{chunks}</a>,
    })}
  </p>
);

Output:

<p>By signing up you agree to our <a href="/terms">terms of service</a>.</p>

The renderer can be any function (chunks: ReactNode) => ReactNode. Combine tags freely — <strong><link>...</link></strong> works as expected.

Server Components

getTranslations is the Server Component counterpart of useTranslations. It's async and reads the per-request locale that setRequestLocale pinned.

// app/[locale]/layout.tsx — Server Component
import { setRequestLocale } from '@api18n/react/server';

export default async function Layout({ children, params }) {
  const { locale } = await params;
  setRequestLocale(locale);
  return <html lang={locale}>{children}</html>;
}
// app/[locale]/page.tsx — Server Component
import { getTranslations } from '@api18n/react/server';

export default async function Page() {
  const t = await getTranslations();
  return <h1>{t('welcome', { name: 'Eduardo' })}</h1>;
}

Strings rendered by getTranslations ship as plain HTML — there's no client-side rehydration cost for the translation. Client Components in the same tree use the singleton via useTranslations as usual.

Mixed client + server example

// app/[locale]/checkout/page.tsx — Server Component
import { getTranslations } from '@api18n/react/server';
import { CheckoutButton } from './checkout-button';

export default async function CheckoutPage() {
  const t = await getTranslations();
  return (
    <main>
      <h1>{t('checkout.title')}</h1>
      <p>{t('checkout.subtitle')}</p>
      <CheckoutButton />
    </main>
  );
}
// app/[locale]/checkout/checkout-button.tsx — Client Component
'use client';

import { useTranslations } from '@api18n/react';

export function CheckoutButton() {
  const t = useTranslations();
  return <button>{t('checkout.confirm')}</button>;
}

Both call t(), both render the same locale, but only the Client Component carries client-side translation state. The Server Component's output is pure HTML.

Type-safe keys (round-trip)

This is the full flow you'd run after pulling new translations.

# 1. Pull
npx api18n pull
# → writes messages/en.json + messages/messages.d.ts

After the pull, messages/messages.d.ts looks like:

import '@api18n/react';
import type { ReactNode } from 'react';

declare module '@api18n/react' {
  interface Messages {
    welcome: { __raw: 'Hello {name}'; name: string | number };
    'cart.items': {
      __raw: '{count, plural, one {# item} other {# items}}';
      count: number;
    };
    'terms.agreement': {
      __raw: 'By signing up <link>terms</link>';
      link: (chunks: ReactNode) => ReactNode;
    };
  }
}

In your component code:

const t = useTranslations();

t('welcome', { name: 'x' });           // ✅
t('cart.items', { count: 3 });          // ✅
t('terms.agreement', {                  // ✅
  link: (chunks) => <a href="/t">{chunks}</a>,
});

// @ts-expect-error — unknown key
t('helo', { name: 'x' });
// @ts-expect-error — missing required `name`
t('welcome');
// @ts-expect-error — count must be number, not string
t('cart.items', { count: '3' });

The four red-x cases all fail TypeScript instead of silently rendering the wrong thing at runtime. Commit messages.d.ts along with your JSON files and PR reviewers see the new shape directly in the diff.

Locale switcher

A complete client-side picker using both useLocale and setLocale.

'use client';

import { useLocale, setLocale } from '@api18n/react';

const LOCALES = [
  { code: 'en', label: 'English' },
  { code: 'pt-BR', label: 'Português' },
  { code: 'de', label: 'Deutsch' },
];

export function LocalePicker() {
  const locale = useLocale();

  return (
    <select value={locale} onChange={(e) => setLocale(e.target.value)}>
      {LOCALES.map(({ code, label }) => (
        <option key={code} value={code}>
          {label}
        </option>
      ))}
    </select>
  );
}

For Next.js apps that also store the locale in a cookie (so server components pick it up after refresh), wrap setLocale with a server action:

'use client';

import { useTransition } from 'react';
import { setLocale } from '@api18n/react';
import { persistLocale } from './actions';

export function LocalePicker() {
  const [pending, start] = useTransition();
  return (
    <select
      disabled={pending}
      onChange={(e) => {
        setLocale(e.target.value);              // immediate client update
        start(() => persistLocale(e.target.value)); // persist to cookie + revalidate
      }}
    >
      {/* options */}
    </select>
  );
}
// actions.ts
'use server';

import { cookies } from 'next/headers';
import { revalidatePath } from 'next/cache';

export async function persistLocale(locale: string) {
  const store = await cookies();
  store.set('NEXT_LOCALE', locale, { path: '/', maxAge: 60 * 60 * 24 * 365 });
  revalidatePath('/', 'layout');
}