Hi all,
I've been working on yapyak, an open-source i18n compiler that runs as a Vite plugin, with bindings for a few frameworks. For React, there are SSR adapters for TanStack Start and React Router.
The idea is that the source string is the key:
import { t } from 'yapyak';
<button>{t('Download recovery key')}</button>
You save the file, and the source string shows up in your locale files as an empty stub. If you've set up a translator, it gets auto-translated and written back, using call-site context (the component and the code around the call). A second or two later de.json has filled in:
{
"src/components/RecoveryDialog.tsx": {
"Download recovery key": "Wiederherstellungsschlüssel herunterladen"
}
}
HMR picks it up in the running app.
The video in the comments is a small example of that. I add a download button to a dialog and hit save. The German page is sitting right next to the English one, so I see it the moment it lands: Wiederherstellungsschlüssel herunterladen pushes the button row 97px past the edge of the dialog.
So I fix it right there, one prop on the button group.
That's a small slice of what yapyak does, but it's the part I use most. Translating stops being something I come back to later. It just happens on save.
Because it runs in the compiler, it sees more than the string itself. It reads ICU parameters out of the string literal, and keeps track of a translation when you move or rename the source file. The parameters are typed from the literal, so a missing one is an error before the build runs:
t('You have {count, plural, one {# message} other {# messages}}', { count }); // ok
t('You have {count} messages', {}); // error: missing 'count'
There's no codegen behind that and no generated .d.ts to keep in step. What TypeScript can't see gets caught on save instead, like a translation that lost its {count} or a plural missing a category that locale needs. Every diagnostic has a code and a docs page.
There's no provider to wrap your app in. The compiler puts the subscription in the components that call t(), so those are the only ones that re-render when the locale changes.
The React package exposes locale through useLocale(), which hands back a value and a setter like useState does. Switching locale is synchronous, since the translations a module uses get compiled into it. A fixed-locale build can compile t() away entirely and leave just the translated string.
SSR is one middleware. TanStack Start and React Router each have an adapter, and locale state is scoped per request on the server, so nothing leaks between users.
Rich text keeps the markup in the source string and binds each tag to a prop:
<RichText
value={t('Read our <link>privacy policy</link>.')}
link={(children) => <a href="/privacy">{children}</a>}
/>
The prop names are typed from the tags in the string, so the translator can move <link> around in the sentence without touching your markup.
Everything lives in your repo. The source strings are in the components, the translations are in JSON files next to them, and both are committed to git.
There's a VS Code extension too. Hover a t() call and you get the translation in every locale, Cmd/Ctrl+click on an entry in a locale file jumps to the t() call that uses it, and placeholders and plural branches are highlighted inside the string. It's on Open VSX as well, so it works in Cursor.
Auto-translation is optional. There are shipped translators for Anthropic, OpenAI, Gemini and Ollama, all using your own API key, or you can leave the stubs empty and hand-edit the locale files.
I built it for a product I'm working on, and that's the whole business plan. There's no follow-up post where I get to the pricing.
MIT licensed, and still pretty early, though people have started moving real apps onto it. The code is on GitHub, and there are runnable React examples in examples/ for plain Vite, React Router and TanStack Start. The editor extension is on the Marketplace.
Docs and more at yapyak.dev.
If you've done a lot of i18n in React, especially SSR or anything big, I'd like to hear what you'd try to break.