SayKit
Integrations

React

Use SayKit with React, components, hooks, and server runtime

@saykit/react is the React integration for SayKit. It does three things:

  1. Provides a <Say> component for rendering translated content, with sub-components for plurals, ordinals, and select.
  2. Provides a <SayProvider> and useSay() for client components.
  3. 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-saykit

Then 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 &middot; 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:

src/main.tsx
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:

src/i18n.ts
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):

  1. calls getLocale(props) and match()es it against your configured locales
  2. loads the matched locale's messages
  3. activates the locale on the shared Say
  4. registers it via setSay()
  5. renders the wrapped component with locale and messages injected

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 Say is registered per request so server components can resolve translations
  • on the client, SayProvider supplies the same locale + messages so the tree hydrates consistently

Next

On this page