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. 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:

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