SayKit
Core Concepts

Configuration

The saykit.config.ts file, locales, buckets, formatters, transformers

Every SayKit project has a saykit.config.ts (or .js) in its root. It's a single source of truth for locales, which files to extract from, where translations live, and how they're parsed and serialised.

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', 'ja'],
  buckets: [
    {
      include: ['src/**/*.{ts,tsx}'],
      output: 'src/locales/{locale}.{extension}',
      formatter: po(),
      transformer: [js(), jsx()],
    },
  ],
});

The first locale in locales is treated as the source locale: the language your code is written in, and the only one extraction writes to. Other locale files are left to your translation management system; untranslated keys resolve through a fallback chain at load time. The config is validated by Zod at load time, mistakes get caught with a clear error.

defineConfig() doesn't just type the config, it also runs it through the Zod schema and returns the normalised result. Use it for both the type hints and the validation.

Loading

The CLI and the build-tool plugins both load your config via resolveConfig() from @saykit/config/features/loader. It searches up from the current working directory for saykit.config.{ts,mts,cts,js,mjs,cjs} and loads the first match.

The file is handed to the runtime as it sits on disk — nothing is copied, transpiled or cached — so relative imports, import.meta.dirname and __dirname all resolve against the config's own directory. A config is free to import a formatter or transformer from alongside it:

saykit.config.ts
import { yaml } from './yaml-formatter.ts';

Reading TypeScript requires Node 22.18+, or a runtime that loads it itself (Bun, Deno, tsx). Note that relative specifiers need the real extension (./yaml-formatter.ts, not .js), and that enums, namespaces and parameter properties are not erasable syntax, so no runtime will accept them.

Top-level schema

Prop

Type

Locales

Locales are arbitrary strings. SayKit doesn't care whether you use BCP 47 (en-GB), ISO 639 (en), or your own naming, whatever you pick is the key it uses everywhere.

locales: ['en', 'fr-CA', 'es-419'];

The source locale is whichever entry you list first. It's the locale your code is written in, and it's the one SayKit will rewrite into when it generates translation files from scratch. The runtime Say class uses the same convention.

Reordering locales so that a different entry sits first means SayKit will treat your existing source messages as if they were in the new source locale. Plan this early.

Fallback locales

Because extraction only writes the source locale, non-source catalogues carry just their real translations. A key that hasn't been translated in a locale is resolved through a fallback chain when the build plugin loads the catalogue.

By default every locale falls back straight to the source. fallbackLocales lets you insert intermediate locales first, most specific first:

saykit.config.ts
export default defineConfig({
  locales: ['en', 'en-GB', 'en-NZ', 'es-MX', 'es'],
  fallbackLocales: {
    'en-NZ': ['en-GB'], // en-NZ → en-GB → en (source)
    'es-MX': 'es', // es-MX → es → en (source)
  },
  buckets: [/* … */],
});

A single fallback can be a bare string; multiple fallbacks are an array. SayKit always appends the source locale as the final entry, so an untranslated key ultimately renders the source string, no locale ever resolves to a missing message.

The chain is resolved and baked into the emitted JS at build time, so the runtime still loads a single locale. Nothing about fallbacks reaches Say. See Extraction → fallback at load time.

Buckets

A bucket is a unit of "files in → translation file out". Most projects have one bucket. Use more when different parts of your app need their own translation files (UI vs emails, client vs server, …).

Prop

Type

Output template

The output template names where translation files go. Two placeholders are required:

  • {locale}, replaced with each locale string
  • {extension}, replaced with the formatter's extension (no leading dot)
output: 'src/locales/{locale}.{extension}';
// → src/locales/en.po, src/locales/fr.po
output: 'translations/{locale}/messages.{extension}';
// → translations/en/messages.po, translations/fr/messages.po

Declared messages

Extraction only sees messages that appear as macros in source files. When a string belongs in the catalogue but has no call site — a browser extension manifest, an app store listing, an email subject, database seed data — declare it on the bucket instead of writing a dummy module for the extractor to find.

messages: {
  // Shorthand: the value is the source string.
  extensionName: 'Reading Time',

  // Long form, for the metadata a descriptor would otherwise carry.
  extensionDescription: {
    message: 'Estimate how long a page will take to read.',
    context: 'store',
    comments: ["The extension's one-line store description."],
  },
},

Each key is the message id, so declared strings always have stable, hand-written keys rather than content hashes — which is exactly what a manifest needs when it names one. They are merged into the catalogue on every saykit extract, alongside everything the transformers find. If a source file happens to use the same id, the declaration wins on text and comments, and the call site still contributes its source reference.

Declared messages have no call site, so they are written without source references. Use comments to tell translators where the string actually shows up.

Multiple transformers

Pass an array when you want different languages in the same bucket:

transformer: [js(), jsx()];

Each transformer decides which files it handles via its match() function. The bucket dispatches files to the matching transformer transparently.

Formatters

A Formatter knows how to parse and stringify a particular translation file format.

import po from '@saykit/format-po';

formatter: po();
formatter: po({ includeReferences: false });

PO is the default and what every SayKit example uses. You can also write your own, for example, JSON or YAML.

Transformers

A Transformer knows how to parse a source language. It declares which files it handles, how to extract messages from them, and how to rewrite macros to runtime calls.

import js from '@saykit/transform-js';
import jsx from '@saykit/transform-jsx';

transformer: [js(), jsx()];

Most projects use the two built-in transformers. You can also write your own for new file types or DSLs.

Full example: multiple buckets

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', 'ja'],
  buckets: [
    {
      include: ['src/app/**/*.{ts,tsx}'],
      exclude: ['**/*.test.*', '**/*.stories.*'],
      output: 'src/locales/{locale}.{extension}',
      formatter: po(),
      transformer: [js(), jsx()],
    },
    {
      include: ['src/emails/**/*.ts'],
      output: 'src/emails/locales/{locale}.{extension}',
      formatter: po(),
      transformer: js(),
    },
  ],
});

Next

On this page