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.tsAfter 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');
}