SayKit
Getting Started

Quickstart

Build a SayKit project end-to-end in a few minutes

This walks through a complete SayKit setup (config, source, extraction, runtime) using a small Vite + React app. The exact framework doesn't matter much; only the bundler plugin and provider change between stacks.

The full source for this and other stacks lives in the SayKit examples.

1. Install

pnpm add saykit @saykit/react
pnpm add -D @saykit/config @saykit/format-po @saykit/transform-js @saykit/transform-jsx unplugin-saykit

2. Configure SayKit

Create saykit.config.ts in your project root:

saykit.config.ts
import { defineConfig } from '@saykit/config';
import po from '@saykit/format-po';
import js from '@saykit/transform-js';
import jsx from '@saykit/transform-jsx';

export default defineConfig({
  locales: ['en', 'fr'],
  buckets: [
    {
      include: ['src/**/*.{ts,tsx}'],
      output: 'src/locales/{locale}.{extension}',
      formatter: po(),
      transformer: [js(), jsx()],
    },
  ],
});

This tells SayKit:

  • The source locale is en (the first entry), and we also want fr.
  • Look for messages in any .ts/.tsx file under src/.
  • Write extracted messages to src/locales/en.po and src/locales/fr.po.
  • Use the PO formatter and the JS + JSX transformers.

3. Wire up the bundler

For Vite, register unplugin-saykit in your config:

vite.config.ts
import saykit from 'unplugin-saykit/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [saykit(), react()],
});

Using Next.js, React Native, Expo, or another Babel-driven pipeline? Add babel-plugin-saykit to your Babel config instead. See the Babel integration.

4. Author some messages

Write messages inline using the say tagged template (or <Say> in JSX):

src/app.tsx
import { Say } from '@saykit/react';
import { useState } from 'react';

function Counter({ name }: { name: string }) {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>
        <Say>Hello, {name}!</Say>
      </p>
      <p>
        <Say.Plural _={count} one="You have 1 message" other={<>You have {count} messages</>} />
      </p>
      <button onClick={() => setCount(count + 1)}>
        <Say>Add one</Say>
      </button>
    </div>
  );
}

Outside React, or anywhere you need a plain string, the same macros hang off a view. Ask the catalogue for one locale and call say on it:

src/notify.ts
import { catalogue } from './i18n';

const say = catalogue.locale('fr');

export function notify(name: string, count: number) {
  document.title = say`Hello, ${name}!`;
  return say.plural(count, {
    one: 'You have 1 message',
    other: `You have ${count} messages`,
  });
}

5. Extract messages

Run the CLI:

pnpm saykit extract

SayKit walks your source, finds every macro, and writes the catalogue files:

src/locales/
  en.po         # source locale, generated from your source
  en.d.po.ts    # auto-generated TS declaration
  fr.po         # other locales, created empty (header only)
  fr.d.po.ts

Extraction only writes the source locale (en). Other locales are created empty the first time and then left untouched, translated content is owned by your translation management system, and untranslated keys fall back to the source string automatically. See extraction for the full picture.

For iterative development, use watch mode:

pnpm saykit extract --watch

en.po will look something like this:

src/locales/en.po
msgid "Hello, {name}!"
msgstr "Hello, {name}!"

msgid "{count, plural,\n  one {You have 1 message}\n  other {You have # messages}\n}"
msgstr "{count, plural,\n  one {You have 1 message}\n  other {You have # messages}\n}"

msgid "Add one"
msgstr "Add one"

6. Translate

Your fr.po starts empty. A translation management system fills it in for you, or, if you translate by hand, copy the msgid lines you want to translate out of en.po and write the msgstr:

src/locales/fr.po
msgid "Hello, {name}!"
msgstr "Bonjour, {name} !"

msgid "Add one"
msgstr "Ajouter un"

You only need the entries you have actually translated, anything missing falls back to the source string.

extract only ever writes the source locale, so your translations are never touched. When source strings get removed or reworded, run saykit clean to strip the entries in fr.po that no longer match anything in en.po:

pnpm saykit clean

7. Provide a catalogue at runtime

Create one catalogue for your app:

src/i18n.ts
import { createCatalogue } from 'saykit';
import en from './locales/en.po';
import fr from './locales/fr.po';

export const catalogue = createCatalogue({ en, fr });

Pick a locale, put a store over the catalogue, and wrap your tree in SayProvider:

src/main.tsx
import { SayProvider } from '@saykit/react/client';
import { createRoot } from 'react-dom/client';
import { createStore } from 'saykit';
import App from './app';
import { catalogue } from './i18n';

const store = createStore(catalogue, catalogue.match(navigator.languages));

createRoot(document.getElementById('root')!).render(
  <SayProvider store={store}>
    <App />
  </SayProvider>,
);

That's it. Your app now renders in the matched locale, and switching is one store.set('fr') away: the store loads the locale if it has to, then re-renders everything below the provider.

Where to next?

On this page