SayKit
Integrations

Babel

Use SayKit with Babel for Next.js, React Native, Metro, Expo, or any Babel-driven pipeline

babel-plugin-saykit is the Babel counterpart to unplugin-saykit. Use it when your build runs Babel directly: Next.js (via .babelrc), React Native, Expo, Metro, or your own Babel setup.

Install

pnpm add -D babel-plugin-saykit

And the supporting packages:

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

Use

Next.js

.babelrc
{
  "presets": ["next/babel"],
  "plugins": [["saykit", { "catalogues": "module" }]]
}

Adding a .babelrc switches Next.js from SWC to Babel for compilation. SayKit doesn't have a SWC plugin, so this is the supported way to use SayKit with Next.js today.

catalogues: 'module' hands catalogues to a loader instead of inlining them — see Catalogues. Wrap your Next config to register it:

next.config.mjs
import { withSayKit } from 'babel-plugin-saykit/next';

export default withSayKit({
  // your config
});

withSayKit derives the rules from your saykit.config.*, one per bucket, for both Turbopack and next --webpack. Each targets that bucket's output exactly, so changing a bucket's path or format needs no change here, and no other file of the same extension is routed through the loader.

Generic Babel

babel.config.js
export default {
  plugins: ['saykit'],
};

Or with an explicit import:

export default {
  plugins: ['babel-plugin-saykit'],
};

React Native / Metro

React Native uses Babel via Metro. Add the plugin to your Babel config:

babel.config.js
module.exports = {
  presets: ['module:metro-react-native-babel-preset'],
  plugins: [['saykit', { catalogues: 'module' }]],
};

Expo

Expo uses babel-preset-expo. Add the plugin alongside:

babel.config.js
module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: [['saykit', { catalogues: 'module' }]],
  };
};

Metro never runs Babel over .json, so catalogues are assembled by a transform worker instead. Wrap your Metro config:

metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const { withSayKit } = require('babel-plugin-saykit/metro');

module.exports = withSayKit(getDefaultConfig(__dirname));

withSayKit also registers non-JSON catalogue extensions (e.g. .po) with Metro's resolver, since a file Metro cannot resolve is not a module it can reload.

What it does

The Babel plugin runs the matching SayKit transformer over every non-node_modules source file, rewriting macros to say.call(...) invocations.

Assembling a catalogue means walking the locale's fallback chain, parsing every file in it with the bucket's formatter, and merging the results into one record so untranslated keys fall back through the chain to the source string. Under the default 'inline' mode the plugin does that itself, at the import; under 'module' it leaves the import alone and the Next.js loader or Metro transformer assembles the record instead.

The result: no .po parser at runtime, no SayKit extractor in the bundle, just small say.call() calls and one plain JS object per locale.

Catalogues

Where that record is assembled is controlled by the catalogues option, and the choice decides whether editing a catalogue hot-reloads.

'inline' (default)

The import is replaced with the record, in the module that imported it. Babel alone is enough — no bundler configuration at all.

The cost is hot reload. A bundler re-reads a module when that module's own bytes change, and a record baked into an importer lives in a file that does not change when you edit a catalogue. So new and edited strings only appear after a cache-clearing restart (expo start --clear, deleting .next).

That is usually fine for a plain Babel build or a library, and painful in a dev server.

'module'

The import is left alone, and the catalogue is served by a bundler integration — babel-plugin-saykit/next for Next.js, babel-plugin-saykit/metro for Metro. Catalogues stay real modules, which is exactly what makes them hot-reloadable.

Set it whenever one of those is wired up. The two are mutually exclusive: if the plugin inlines the import, the integration is never asked for the module and nothing hot-reloads.

Those two are the whole list, because they are the two bundlers that cannot be reached any other way — Metro never runs Babel over .json, and Turbopack runs loaders but not plugins. On webpack, Vite, Rollup or esbuild proper, use unplugin-saykit and skip the Babel plugin's catalogue handling entirely.

Under webpack and Turbopack the fallback files are registered as dependencies too, so editing a source locale updates every locale that falls back to it.

Under Metro only the locale's own file is tracked. Metro keys its transform cache on each file's bytes and offers no equivalent of addDependency, so editing a fallback locale (e.g. en.json while viewing fr) does not refresh the locale that falls back to it. Editing the locale you are viewing works normally.

Only static imports of catalogues are handled. import('./locales/en.po') (dynamic import) is not. For dynamic locale loading, use a Say loader and let your bundler handle the imports.

unplugin vs Babel: which one?

Use unplugin-saykit when…

Your bundler is Vite, Rollup, Rolldown, Webpack, Rspack, esbuild, Farm, or Bun, and you're not pinned to Babel.

Use babel-plugin-saykit when…

Your build pipeline runs Babel directly: Next.js, React Native, Expo, Metro, or your own Babel config.

Next

On this page