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.
<Say> works equally in server and client components, the package switches implementation based on the react-server condition.
Plurals, ordinals, select
<Say> exposes sub-components for ICU branching:
<Say.Plural _={count} one="# item in your cart" other="# items in your cart" />
<Say.Ordinal _={position} _1="1st" _2="2nd" _3="3rd" other="#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 {...}.
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