React
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.
Install
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>
<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.
Naming tags
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.
Elements that share a name but differ in any way are a build error. Each would compile to its own
prop, and a translator reordering the sentence has to be able to tell them apart. Give those
distinct names, link_docs and link_faq rather than link twice. Elements you leave untagged
are unaffected, they keep their numbers.
Plurals, ordinals, select
<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" />JSX prop names can't start with a digit, so numeric branch keys (1, 2, …) get a
leading underscore (_1, _2). SayKit strips the underscore during extraction, so the resulting
ICU is 1 {...}, 2 {...}.
Numbers, dates, times
<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 <Say>. 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.
Descriptors
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>Client components
import { SayProvider, useSay } from '@saykit/react/client';<SayProvider>
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.
useSay
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 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.
Server runtime
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
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.
Wrap every segment that renders messages, not just the outermost one. A framework is free to
render a page before the layout above it - Next.js does - so a layout's view is not established
yet when its page runs, and getSay() there would throw.
setSay
If you have already resolved the view yourself, establish it directly:
import { setSay } from '@saykit/react/server';
setSay(await catalogue.load('fr'));One view per request. React renders a server component's children after it returns, so a view does
not end where a subtree does: a second view established for another locale in the same request
takes over for everything rendered after it, including the messages <SayProvider> serialises to
the client. Development warns when it happens. To render a second locale, give it its own request,
or resolve its view with catalogue.load(locale) and pass it to the components that need it.
getSay
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.
Mental model
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
Next
- Vite (unplugin): the build-tool plugin
- Babel: for React Native, Expo, and other Babel pipelines
- API reference: every export