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-saykit2. Configure SayKit
Create saykit.config.ts in your project root:
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 wantfr. - Look for messages in any
.ts/.tsxfile undersrc/. - Write extracted messages to
src/locales/en.poandsrc/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:
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):
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:
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 extractSayKit 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.tsExtraction 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 --watchen.po will look something like this:
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:
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 clean7. Provide a catalogue at runtime
Create one catalogue for your app:
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:
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.