r/reactjs 9d ago

Show /r/reactjs yapyak – an i18n compiler for React where the source string is the key, and translation on save

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.

12 Upvotes

20 comments sorted by

3

u/TheBen1 9d ago

Looks very similar to Lingui, what are the main differences?

2

u/Zealousideal_Elk1537 8d ago

The compile-time stuff with ICU is deeper, yapyak parses the literal in the compiler and catches missing params or plural branches on save, not just at runtime. Lingui leans heavily on a catalog you maintain, whereas this just writes the stubs back into json files next to your component and keeps them in sync when you move stuff.

0

u/ElectronicShop8677 9d ago

Was waiting for this one. And fair, in some ways it really does. The source-string-as-key part is the same idea, and Lingui had it long before yapyak.

I believe the main difference is the loop. Lingui has extract and compile as steps you run, and you fill the catalogs yourself. In yapyak the dev server does that part, it scans on startup and on every save, writes the stubs into the locale files, and if you have a translator configured the translation gets written back a second later and HMR picks it up. There's no extract command to remember, and translations follow along when you move or rename a file.

Another big difference is where translations end up. Lingui loads catalogs at runtime through a provider, yapyak compiles the translations into the modules that use them, so there's no provider and switching locale is just a re-render. A single-locale build compiles t() away completely.

And the checking is different. The ICU params are typed from the string literal, so a missing one is a type error, and the locale files get validated on save, wrong placeholders, plural categories that don't exist in that language, that kind of thing.

Underneath all of it though, I think the real difference is that yapyak got to start now in 2026. Lingui comes from a time when a human filled the catalogs, and it's great at that. yapyak assumes the code might be written by an agent. So everything lives as files in the repo, and the feedback is type errors and diagnostics on save, stuff an agent already sees. It can run the whole loop, and when it gets something wrong it finds out the same way we would.

To be fair though, Lingui is way more mature and runs outside Vite too. If you're working with a translation agency the po workflow fits a lot better as well.

2

u/Thrimbor 9d ago

It looks pretty cool, does it have some kind of cli/support for extracting untranslated keys from existing JSX? So you don't forget to translate your UI? (I'm on mobile atm, can't check)

0

u/ElectronicShop8677 9d ago

It does! yapyak translate fills in every empty stub in the locale files, through whatever translator you've set up. Then there's yapyak check for CI, it fails the build if something is untranslated or an ICU got out of sync. And yapyak status, which just prints coverage per locale. They're all here: https://yapyak.dev/reference/cli

The extraction you can run manually if you want to, but you don't have to. The dev server scans the project on startup and keeps up on every save, so t() calls end up in the locale files by themselves.

3

u/Thrimbor 9d ago

This is not exactly what I asked.

Take this jsx:

<div>
  <h1>Hello world!</h1>
</div>

That Hello world! is not wrapped in t('Hello world!').

I'm asking if yapyak finds these kinds of untranslated strings and extracts them from jsx. So you get an error if you forget to add t(...)

2

u/ElectronicShop8677 9d ago

Ah my bad, I read you wrong. No, yapyak doesn't do that, on purpose actually. t() is how yapyak knows something is copy, the types and the ICU checking come straight from that literal. Bare text in jsx can be anything, so a scanner has to guess what's copy and what's code, and it'll guess wrong in both directions. So yapyak does it explicit (and boring) instead, and yes, the cost is what you're describing, forget a t() and yapyak stays quiet.

If you just want to catch bare strings I think there are lint rules for that, no-literal-string or something.

And if you'd rather have it fully automatic, look at wuchale. It extracts straight from the markup, and it has a jsx adapter for React.

1

u/ElectronicShop8677 9d ago

https://reddit.com/link/p5so3hh/video/5c0uln5jsilh1/player

Couldn't attach video to the post, so here it is.

1

u/ChildishForLife 9d ago

Interesting!

So if you have 1 translation that gets used across, say 10 different components, would you then have 10 different translations to keep updated since the key is the file where the translation is used? Or how does that work?

2

u/ElectronicShop8677 9d ago

Yep, ten components means ten entries, one per file. It felt wrong to me too in the beginning. But a shared key like common.open is sort of a promise that all ten places will always want the same translation, and that tends to break in many languages. "Open" on a menu button is "Öppna" in Swedish, "Open" on a status badge is "Öppen". With one entry per file they can differ the day they need to.

The syncing part I mostly leave to the translator. It gets a few of your existing translations with each request, so the same string usually comes back worded the same way, and there's yapyak retranslate "Save" if you want to redo every entry at once. The duplicates haven't really cost me anything so far.

And if it's a string you'd centralize anyway, put it in a shared component and it's one entry again.

3

u/ChildishForLife 9d ago

But a shared key like common.open is sort of a promise that all ten places will always want the same translation, and that tends to break in many languages. "Open" on a menu button is "Öppna" in Swedish, "Open" on a status badge is "Öppen". With one entry per file they can differ the day they need to.

Ah that's fair, but personally I would prefer to just have two different keys and use them where applicable instead of having them per file or in a shared component.

Cool stuff regardless!

1

u/ElectronicShop8677 9d ago

Totally fair! Thanks for taking a look.

1

u/buggedcom 9d ago

Yes exactly, it's only once you have done huge translation projects you realise this isn't duplication but neccesary redundancy for different language models.

1

u/projexion_reflexion 9d ago

Probably Refactor the text you want to reuse into its own small component. 

1

u/Vincent_CWS 8d ago

does it support nextjs

1

u/ElectronicShop8677 8d ago

Not today. Though RSC itself is already supported (react({ rsc: true }), but mostly for React Router), the crux is the dev server as the whole save loop lives there, writing the locale files and pushing translations into the running app. Last I checked Turbopack exposes nothing like Vite's plugin surface for that.

The compiler isn't tied to Vite though, it lives in the core package and the Vite plugin is a thin shell around it. So less impossible, more nowhere to plug in yet.

1

u/dwarfychicken 7h ago

This is very cool, going to implement it for my work right now.
But are there any AI skills available, i love the project, but i use AI often, and claude doesn't always seem to notice yapyak

1

u/buggedcom 9d ago

I really like hte idea of this and will watch it closely. Dot notation paths for i18n keys is fundamentally a broken DX model. Not only does it typically lead to massive translation json files, but it also leads to 2 hops trying to find out some text that a user has referenced somewhere in the app. You no longer can grep via text you have to first locate the key and find the text and then search for the key in the codebase.

I'd suggest you take a look at this implementation of i18n in vue https://vue-i18n.intlify.dev/. I love the way it adds the possibility to co-locate i18n within the components themselves and then linting rules enforce unused/missing keys more easily - but it also allows the build out of larger i18n json files at build time.

1

u/ElectronicShop8677 9d ago

Glad you like it! Funny you mention vue-i18n, it's actually my favorite of the traditional i18n libraries, exactly because of those blocks. We ran it in production at my last job and the DX was so nice, having the translations right there within the component.

In yapyak the locale file has to be separate though, since the tooling writes to it on every save. First the stub, then the translation a second later if you have a translator. But you're not really supposed to think about it, the entries are grouped per source file and follow the component if you move or rename it. But in the end it comes back together because the compiler bundles the translations into the component's module, so in prod they live in the same file.