| title | React |
|---|---|
| description | Use SayKit with React, components, hooks, and server runtime |
@saykit/react is the React integration for SayKit. It does three things:
- Provides a
<Say>component for rendering translated content, with sub-components for plurals, ordinals, and select. - Provides a
<SayProvider>anduseSay()for client components. - Provides a small server runtime (
withSayandgetSay()) for server-rendered apps.
The package uses the react-server export condition, so <Say> works in both server and client components without two different imports.
pnpm add @saykit/react saykit
pnpm add -D @saykit/config @saykit/format-po @saykit/transform-js @saykit/transform-jsx unplugin-saykitThen add a SayKit build-tool plugin: unplugin-saykit for Vite/Rollup/etc. (including TanStack Start), or babel-plugin-saykit for Babel-based pipelines like Next.js, React Native, and Expo.
<Say> is the primary way to render translated content. Write your text and embedded variables naturally; the transform extracts the literal text and rewrites the element to render the translation.
import { Say } from '@saykit/react';
function CheckoutSummary({ items, total }: { items: number; total: string }) {
return (
<p>
<Say>
{items} items · total <strong>{total}</strong>
</Say>
</p>
);
}Variables become named ICU placeholders ({items}, {total}). JSX elements inside <Say> become numbered tags (<0>{total}</0>) so translators can reorder them. Childless elements extract as self-closing tags (<1/>), so there is nowhere for a translator to insert content an icon never expected.
<Say> works equally in server and client components, the package switches implementation based on the react-server condition.
A numbered tag tells a translator nothing about what it does. Add say-tag to any element inside <Say> to name it:
<Say>
Signed in as <strong say-tag="bold">{user}</strong>
</Say>The message extracts as Signed in as <bold>{user}</bold>. The attribute is compile-time only, it is stripped from the element and never reaches the DOM or a native view.
Name the tag after what it does to the text, bold, link, icon, not after the content it happens to wrap. A translator sees only the tag name and the sentence around it, so <bold> tells them how the text will look, while <user> tells them nothing they can act on.
Naming is opt-in and per element, so untagged elements keep their numbers alongside named ones. A tag must be a static string, and a valid identifier: a letter or underscore followed by letters, digits, or underscores.
say-tag names elements. To name a value you interpolate, wrap it in a single-key object: <Say>Signed in as {{ who: user.profile.name }}</Say>. Values follow the same rule as tags, a repeat is fine when it is the same value and a build error when it is not. See Placeholder names.
Two tagged elements in one message can share a name as long as they are identical, the same element
with the same props, as <b say-tag="bold"> twice is. They compile to one prop, and a translator
still sees a single <bold> they can move around the sentence. Only the props are compared, not the
content, since that comes from the translation.
<Say> exposes sub-components for ICU branching:
<Say.Plural _={count} one={<>{count} item in your cart</>} other={<>{count} items in your cart</>} />
<Say.Ordinal _={position} _1="1st" _2="2nd" _3="3rd" other={<>{position}th</>} />
<Say.Select _={gender} male="He liked it" female="She liked it" other="They liked it" /><Say.Number>, <Say.Date>, and <Say.Time> format a value the way the active locale writes it.
They are fragments rather than whole messages, so they usually nest inside a <Say>:
<Say>
You have <Say.Number _={items.length} /> items, due
<Say.Date _={{ dueAt }} style="medium" />
</Say>style is optional: integer or percent on <Say.Number> (or a pattern like #,##0.00), and
short, medium, long, or full on <Say.Date> and <Say.Time>. Every one of them also takes
an ICU skeleton, which is how you ask for a currency or for fields the named styles cannot spell:
<Say>
Total <Say.Number _={total} style="::currency/EUR" /> since
<Say.Date _={{ joined }} style="::yMMMM" />
</Say>They also stand on their own, where there is nothing to translate and nothing is extracted, as a
locale-aware replacement for calling Intl yourself:
<td>
<Say.Number _={row.total} style="::currency/EUR" />
</td>
<time dateTime={post.publishedAt.toISOString()}>
<Say.Date _={post.publishedAt} style="long" />
</time>See Messages for what each one extracts to.
The{"{' '}"} spacing escape is safe inside a ``. A literal expression child is
read as the text
it renders as, so it folds into the words around it and reaches a translator as one run of text
rather than as a placeholder they can neither see nor move.
To attach metadata to a JSX message, a custom id or context, pass it as a prop:
<Say context="noun">Post</Say>
<Say context="verb">Post</Say>
<Say id="checkout.review">Review your order</Say>import { SayProvider, useSay } from '@saykit/react/client';Render SayProvider once near the top of your client tree. It takes either a store, or a locale and its messages.
A store owns the catalogue, so it can switch locale at runtime. That is what a browser app wants: every locale is loadable, and switching re-renders the tree.
Build the store in your i18n module and export it, so the provider and everything that switches locale hold the same one:
import { createCatalogue, createStore } from 'saykit';
import en from './locales/en.po';
import fr from './locales/fr.po';
export const catalogue = createCatalogue({ en, fr });
export const store = createStore(catalogue, catalogue.match(navigator.languages));import { SayProvider } from '@saykit/react/client';
import { store } from './i18n';
<SayProvider store={store}>
<App />
</SayProvider>;A store is a live object and cannot cross the server/client boundary, so a server-rendered app hands over the locale and its messages instead:
<SayProvider locale={locale} messages={messages}>
<App />
</SayProvider>That form has only the one locale the server sent, so it cannot switch: set rejects anything else. Switch by navigating, and let the server render the new locale. See createWithSay, which fills these props in for you.
Anything beneath the provider, <Say>, <Say.Plural>, useSay(), resolves against the current locale.
If you need the view itself in a client component (for example, to compose a runtime string in an event handler), call useSay():
function ConfirmButton({ count }: { count: number }) {
const say = useSay();
return (
<button onClick={() => alert(say`Delete ${count} items?`)}>
<Say>Delete</Say>
</button>
);
}useSay() subscribes to the provider's store, so the component re-renders when the locale changes and the view it returns is the current one. The view itself is immutable: it formats and reads messages but cannot switch locale.
Switching is the store's job, and the store is a module-scope value you already hold, so a locale picker imports it rather than reaching for a hook:
import { useSay } from '@saykit/react/client';
import { store } from '../i18n';
function LocalePicker() {
const say = useSay();
return (
<select value={say.locale} onChange={(event) => store.set(event.target.value)}>
{/* … */}
</select>
);
}store.set(locale) loads that locale's messages if the catalogue does not have them yet, then swaps the view every consumer reads.
import { createWithSay, getSay, setSay } from '@saykit/react/server';The two halves mirror each other: withSay is to getSay() what <SayProvider> is to useSay(). Each establishes a view, and each hands the same kind of value back.
createWithSay(catalogue) binds a withSay to your catalogue, normally once beside the catalogue itself:
import { createWithSay } from '@saykit/react/server';
export const withSay = createWithSay(catalogue);withSay(Component, getLocale) wraps a server component so its view is negotiated, loaded and published into React's per-request cache() before the component renders. A concurrent request rendering another locale reads its own.
import { SayProvider } from '@saykit/react/client';
import { catalogue, withSay } from '../../i18n';
async function RootLayout({ params, children }) {
const { locale } = await params;
return (
<html lang={catalogue.match(locale)}>
<body>
<SayProvider>{children}</SayProvider>
</body>
</html>
);
}
export default withSay(RootLayout, (props) => props.params.then((params) => params.locale));<SayProvider> takes no props here. In a server component it resolves to the react-server build of @saykit/react/client, which reads the established view and serialises that locale and its messages across the boundary, which is all a client component can be given.
If you have already resolved the view yourself, establish it directly:
import { setSay } from '@saykit/react/server';
setSay(await catalogue.load('fr'));getSay() returns the view established for the request. It is the server counterpart of useSay():
import { getSay } from '@saykit/react/server';
function Page() {
const say = getSay();
return <h1>{say`Welcome back!`}</h1>;
}Reach for it when you need the locale as data rather than a rendered message:
const say = getSay();
const price = new Intl.NumberFormat(say.locale, { style: 'currency', currency: 'EUR' }).format(
total,
);It throws if nothing established a view for the request.
A simple way to think about the integration:
- render translated content with
<Say>in any component, server or client - on the server,
withSaybinds one view per request, andgetSay()reads it - on the client,
SayProvidersupplies a store to follow, or the single locale the server sent, so the tree hydrates consistently
- Vite (unplugin): the build-tool plugin
- Babel: for React Native, Expo, and other Babel pipelines
- API reference: every export