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 (
setSay,getSay,unstable_createWithSay) 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 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>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, with the active locale and its messages:
import { SayProvider } from '@saykit/react/client';
import say from './i18n';
const locale = say.match([navigator.language]);
say.activate(locale);
<SayProvider locale={locale} messages={say.messages}>
<App />
</SayProvider>;Internally, SayProvider constructs a frozen Say instance scoped to the locale you pass in. Anything beneath it, <Say>, <Say.Plural>, useSay(), resolves against that locale.
useSay
If you need the Say instance 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() returns a frozen Say, you can read messages and format strings from it, but you can't activate a different locale or load more messages.
Server runtime
import { setSay, getSay, unstable_createWithSay } from '@saykit/react/server';Server rendering uses React's per-request cache() to register a Say instance for the current request.
setSay
Call setSay() once per request, before any server component renders a <Say>:
import { setSay } from '@saykit/react/server';
import { Say } from 'saykit';
const say = new Say({ locales: ['en', 'fr'], messages: { en, fr } });
say.activate('fr');
setSay(say);setSay() clones and freezes whatever you pass, the registered instance is request-scoped and won't mutate any shared state.
getSay
getSay() returns the registered instance for the current request. It's the server equivalent of useSay():
import { getSay } from '@saykit/react/server';
export default async function Page() {
const say = getSay();
return <h1>{say`Welcome back!`}</h1>;
}unstable_createWithSay
For frameworks that pass props into root components (a route's layout, a page component), unstable_createWithSay builds a withSay() higher-order component that handles the per-request dance:
import 'server-only';
import { unstable_createWithSay } from '@saykit/react/server';
import { Say } from 'saykit';
import en from './locales/en.po';
import fr from './locales/fr.po';
const say = new Say({ locales: ['en', 'fr'], messages: { en, fr } });
export const withSay = unstable_createWithSay(say);
export default say;import { SayProvider } from '@saykit/react/client';
import say, { withSay } from './i18n';
async function RootLayout({ locale, messages, children, ...props }) {
return (
<html lang={locale}>
<body>
<SayProvider locale={locale} messages={messages}>
{children}
</SayProvider>
</body>
</html>
);
}
export default withSay(RootLayout, (props) => getLocaleFrom(props));withSay(Component, getLocale):
- calls
getLocale(props)andmatch()es it against your configured locales - loads the matched locale's messages
- activates the locale on the shared
Say - registers it via
setSay() - renders the wrapped component with
localeandmessagesinjected
The component then passes those into <SayProvider> so client components hydrate correctly.
Mental model
A simple way to think about the integration:
- render translated content with
<Say>in any component, server or client - on the server, one
Sayis registered per request so server components can resolve translations - on the client,
SayProvidersupplies the same locale + messages 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