<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arvid Andersson</title>
    <description>The latest articles on DEV Community by Arvid Andersson (@arvid_andersson_0a598fa45).</description>
    <link>https://dev.to/arvid_andersson_0a598fa45</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1939937%2Fff79f2f3-1b43-49fd-a66c-ebcda9008a94.jpg</url>
      <title>DEV Community: Arvid Andersson</title>
      <link>https://dev.to/arvid_andersson_0a598fa45</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arvid_andersson_0a598fa45"/>
    <language>en</language>
    <item>
      <title>Should Translations Go in the Same PR or a Separate One?</title>
      <dc:creator>Arvid Andersson</dc:creator>
      <pubDate>Fri, 21 Aug 2026 11:40:46 +0000</pubDate>
      <link>https://dev.to/arvid_andersson_0a598fa45/should-translations-go-in-the-same-pr-or-a-separate-one-2c3h</link>
      <guid>https://dev.to/arvid_andersson_0a598fa45/should-translations-go-in-the-same-pr-or-a-separate-one-2c3h</guid>
      <description>&lt;p&gt;If you ask an AI assistant how to automate i18n in CI, it will most often recommend a separate translation PR. The reasoning goes: a separate PR is cleaner, reviewers can tell source-code changes apart from localization churn and slow human translations don't block feature development.&lt;/p&gt;

&lt;p&gt;That's a real argument, but from watching how teams actually work with translations we've landed somewhere else: most times translations belong in the pull request that introduced the strings. &lt;a href="https://localhero.ai/blog/how-kundo-automated-localization" rel="noopener noreferrer"&gt;Kundo&lt;/a&gt; moved nine languages off an agency and a TMS to automated translations on every pull request. A glossary and style guide keep the output on-brand by default, so most of it just flows and the team steps in on the strings that warrant a second look.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5novkdgyld3dlcwarv3x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5novkdgyld3dlcwarv3x.png" alt="Separate PR leaves main with the English strings while the French ones arrive later; same PR ships both together as one unit" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The case for a separate PR
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The diff stays readable.&lt;/strong&gt; If your feature PR touches eight files and the bot adds translations for nine languages, your diff goes from eight files to seventeen. A reviewer scrolling for the actual logic change has to skip past a lot of YAML.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Translations can't block the feature.&lt;/strong&gt; If translation takes hours, or a human has to review the Japanese, the feature waits. A separate PR decouples them: ship the feature now, merge translations when they're ready.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A clear gate.&lt;/strong&gt; Some teams genuinely want an explicit approval step for anything customer-facing owned by someone who isn't the developer.&lt;/p&gt;

&lt;p&gt;Those are the reasons you usually get. If you take nothing else from this post: if your translations involve slow human turnaround and you have no way to review them outside a code diff, a separate PR is the right call. That was the world most of this advice was written in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we do it the other way
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A feature isn't done until it's translated.&lt;/strong&gt; This is the core of it. If your feature merges to &lt;code&gt;main&lt;/code&gt; in English and the translations arrive in a PR two days later, then for two days &lt;code&gt;main&lt;/code&gt; contains a half-finished feature. Anyone deploying in that window ships English strings to Japanese users. The "separate PR doesn't block the feature" argument is really "separate PR lets you merge something incomplete", which is only an advantage if you think of translations as an afterthought rather than part of the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review happens where the reviewer is.&lt;/strong&gt; The diff-churn objection assumes translations get reviewed in the diff. That's the part we'd push back on: a YAML diff is a bad place to review copy. Your PM doesn't read it. Your native Japanese speaker on the support team doesn't have a GitHub account. Move that review into a dashboard, where someone sees the source string next to the suggestion and can fix it before merge and the diff never needs to be the review surface at all. The churn objection dissolves, because nobody was reading that part of the diff anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Git history stays clean.&lt;/strong&gt; Separate translation PRs pile up. They go stale when the feature branch changes. They get merged out of order. They occasionally conflict with each other. Ship weekly for six months and that's twenty-odd "chore: translations" merges interleaved with the real work and bisecting through it is worse not better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One unit of work.&lt;/strong&gt; The feature, its strings and its translations are one change. Reviewing them together is how you catch that the new button label doesn't fit in German before it reaches production, rather than after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consistency is the actual bar, not coverage.&lt;/strong&gt; Translations are good when they sound like your product: same formality throughout, same term for the same concept every time, "Dashboard" not "Übersicht" in one screen and "Dashboard" in the next. Getting the strings into the repo is the easy half. That's what a &lt;a href="https://localhero.ai/blog/style-guide-glossary-setup" rel="noopener noreferrer"&gt;style guide and glossary&lt;/a&gt; are for. Keeping it in the same PR is what makes them easy to enforce: the person who wrote the string is right there, still holding the context and can see the German before it merges. Fix a bad term then and it costs a comment. Fix it after three more features have copied the wording and it costs an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where should translations be reviewed?
&lt;/h2&gt;

&lt;p&gt;The tradeoff turns on one question: &lt;strong&gt;do you have a review surface outside the diff?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you don't, the separate-PR advice can be sound and the AI assistants recommending it aren't wrong. Without somewhere to review translations they either land in your diff unreviewed or they block your merge.&lt;/p&gt;

&lt;p&gt;If you do, the calculus flips. Translations stop being noise in the code review and become their own reviewable thing, handled by whoever should be handling them. The feature and its translations ship as one unit. Your diff has more lines in it and nobody minds, because nobody is reading them line by line.&lt;/p&gt;

&lt;p&gt;That's the design choice behind how &lt;a href="https://localhero.ai" rel="noopener noreferrer"&gt;Localhero.ai&lt;/a&gt; works. The &lt;a href="https://localhero.ai/blog/automate-i18n-github-actions" rel="noopener noreferrer"&gt;GitHub Action&lt;/a&gt; picks up only the keys that are new or changed, translates them against your glossary and style guide, runs &lt;a href="https://localhero.ai/blog/translation-quality-at-scale" rel="noopener noreferrer"&gt;quality checks&lt;/a&gt; on the result and commits it into the pull request that introduced the strings. That takes seconds and it happens before anyone looks at the PR.&lt;/p&gt;

&lt;p&gt;Then, if someone wants to check a string, the &lt;a href="https://localhero.ai/docs/review" rel="noopener noreferrer"&gt;review happens in a dashboard&lt;/a&gt;: the source string, the suggestion, the glossary term it should have used and an edit box. Edits sync back to the same pull request as a commit before it merges. No GitHub account needed, no code diff to read, and no separate PR to chase. This isn't a sign-off on every string, either. Once a glossary and style guide are in place the output is consistent by default, so review is something you reach for on the copy that matters, not a gate everything has to clear.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhrii7tk0x9l2x20z67fs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhrii7tk0x9l2x20z67fs.png" alt="The Localhero.ai review view for a pull request: one source string with every target language beside it, editable in place, and a sync button back to GitHub" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That ordering is deliberate. The same-PR model only holds up if reviewing outside the diff is genuinely good, which is why most of our product work goes there rather than into the translation step. Translation quality is largely a solved problem now, given the right glossary, context and checks. Making the output reviewable by the person who owns the words is the part still worth building.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a separate PR can be the better call
&lt;/h2&gt;

&lt;p&gt;Three cases, and the first one is a cost rather than a reason to switch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your diff really is bigger.&lt;/strong&gt; This isn't free. If your team reviews PRs by reading every changed line top to bottom, you'll feel it. Most reviewers collapse or filter locale files when they hit a big PR, which handles it, but it's a real change to the review experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If translations are slow, this hurts.&lt;/strong&gt; The model depends on translations arriving in seconds, not days, which is the whole reason automating that step matters. If a human has to produce the first draft, the feature genuinely waits, and the fix is usually to flip the order: let the machine write the first pass so the merge isn't blocked, and keep the human on the strings worth their attention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some teams might need a gate.&lt;/strong&gt; Regulated industries, legal copy, brand-critical marketing strings. If a lawyer or a compliance officer has to sign off before customer-facing text merges, and the sign-off needs its own audit trail separate from code review, a separate PR is the better answer and we would tell you the same thing. Same if the translations are owned by an outside vendor on their own schedule. The argument in this post is about product UI strings that your own team owns, not every string in every company.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same PR or separate for translations: how to decide
&lt;/h2&gt;

&lt;p&gt;The advice to use a separate PR made sense when translation meant sending a spreadsheet to an agency and waiting. Most of the tooling and most of the advice written about the tooling still assumes that world.&lt;/p&gt;

&lt;p&gt;If translations arrive in seconds and can be reviewed somewhere a non-developer can work, keeping them in the same PR is simpler: one unit of work, one review, one merge, and &lt;code&gt;main&lt;/code&gt; is never half shipped.&lt;/p&gt;

&lt;p&gt;The PR question is a real one and your team will have opinions about it, so it's worth working through rather than inheriting a default. The part we'd start from is review. If someone needs to read the generated strings before they ship, and usually someone does in some form, work out who that person is and where they'd do it. The PR shape mostly follows from that answer.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>github</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to Automate i18n Translations with GitHub Actions: 3 Approaches</title>
      <dc:creator>Arvid Andersson</dc:creator>
      <pubDate>Fri, 21 Aug 2026 08:05:17 +0000</pubDate>
      <link>https://dev.to/arvid_andersson_0a598fa45/how-to-automate-i18n-translations-with-github-actions-3-approaches-48nd</link>
      <guid>https://dev.to/arvid_andersson_0a598fa45/how-to-automate-i18n-translations-with-github-actions-3-approaches-48nd</guid>
      <description>&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt; You can automate translations by triggering AI translations on every pull request with GitHub Actions. No more waiting on translations before shipping. This post walks through different approaches that work with your React, Rails, Next.js, and Django projects.&lt;/p&gt;




&lt;p&gt;I've seen the same pattern at almost every team that ships a product in multiple languages. The feature is built, tests pass, the PR is ready to merge, but translations aren't done. Either the release gets delayed or it goes out with missing strings in half the languages. &lt;/p&gt;

&lt;p&gt;The root cause, in my experience, is that translations live outside the development workflow, even in projects where the translation files are checked into the codebase. They end up being managed through spreadsheets, Slack channels or external platforms that aren't connected to your git flow. Every new feature requires someone to manually coordinate getting the translations done. And that someone is usually a developer who has better things to do.&lt;/p&gt;

&lt;p&gt;The fix is straightforward: make translations part of your CI/CD pipeline. When a developer pushes code with new translation keys, translations should be generated automatically, just like tests run automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why GitHub Actions for translations?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.github.com/en/actions" rel="noopener noreferrer"&gt;GitHub Actions&lt;/a&gt; is the natural place to automate translations because it's already where your code lives. Instead of syncing files back and forth between your repo and an external platform like &lt;a href="https://lokalise.com" rel="noopener noreferrer"&gt;Lokalise&lt;/a&gt; or &lt;a href="https://phrase.com" rel="noopener noreferrer"&gt;Phrase&lt;/a&gt;, translations happen right in the pull request workflow.&lt;/p&gt;

&lt;p&gt;Here's what an automated translation workflow looks like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You add new translation keys in the source language&lt;/li&gt;
&lt;li&gt;A pull request is opened for the changes&lt;/li&gt;
&lt;li&gt;GitHub Action detects new or changed keys&lt;/li&gt;
&lt;li&gt;Translations are generated (via API call to a translation service)&lt;/li&gt;
&lt;li&gt;Translated files are committed back to the branch&lt;/li&gt;
&lt;li&gt;Reviewer sees both code changes and translations in the same PR&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This means translations are always part of the code review process, always version-controlled, and always ready to ship with the feature. No waiting for someone to get back to you.&lt;/p&gt;

&lt;p&gt;For most teams, auto-commit to the PR branch plus a staging review works well. Translations land automatically, and your team catches issues in context before production. For marketing copy or anything brand-sensitive, you might want an explicit approval step before merging.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdzuvpa4lpf3viq9mpf0u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdzuvpa4lpf3viq9mpf0u.png" alt="Translation workflow in a pull request" width="800" height="159"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 1: DIY with a LLM API
&lt;/h2&gt;

&lt;p&gt;If you want full control, you can build a simple GitHub Action that calls the &lt;a href="https://platform.openai.com/docs/api-reference" rel="noopener noreferrer"&gt;OpenAI API&lt;/a&gt; (or any LLM API) directly to translate your i18n files.&lt;br&gt;
It's a quick approach for smaller projects where you don't need to ensure brand voice consistency and similar. It can work well for getting started.&lt;/p&gt;

&lt;p&gt;Here's a minimal example for a project using JSON translation files:&lt;/p&gt;
&lt;h3&gt;
  
  
  The translation script
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/translate.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SOURCE_LOCALE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TARGET_LOCALES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sv&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LOCALES_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./locales&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;translateWithOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;targetLang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.openai.com/v1/chat/completions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You are a professional translator. Translate the following JSON values from English to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;targetLang&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Keep the JSON keys unchanged. Only return valid JSON, no explanation.`&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sourceFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LOCALES_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SOURCE_LOCALE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sourceTexts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;TARGET_LOCALES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LOCALES_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetFile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceTexts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: all keys present, skipping`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: translating &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; keys...`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;translated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;translateWithOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;translated&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: done`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  The GitHub Action workflow
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/translate.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Auto-translate i18n&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;locales/en.json'&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;translate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.head_ref }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Translate missing keys&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.OPENAI_API_KEY }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node scripts/translate.js&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Commit translations&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git config user.name "github-actions[bot]"&lt;/span&gt;
          &lt;span class="s"&gt;git config user.email "github-actions[bot]@users.noreply.github.com"&lt;/span&gt;
          &lt;span class="s"&gt;git add locales/&lt;/span&gt;
          &lt;span class="s"&gt;git diff --cached --quiet || git commit -m "Auto-translate: update i18n files"&lt;/span&gt;
          &lt;span class="s"&gt;git push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ikl6a0u4hogr0hjv7ke.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ikl6a0u4hogr0hjv7ke.png" alt="GitHub Action commits translations to the PR" width="800" height="328"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; Full control, no external dependencies, cheap (just OpenAI API costs).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; No brand voice management, no &lt;a href="https://localhero.ai/blog/style-guide-glossary-setup" rel="noopener noreferrer"&gt;glossary support&lt;/a&gt;, no quality controls, no context awareness, no UI for managing translations, and you have to maintain the script and prompts yourself. For a side project, that's fine. For a production app with 10 languages, &lt;a href="https://localhero.ai/blog/why-not-just-use-openai-api" rel="noopener noreferrer"&gt;it gets old fast&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Framework-specific setup notes
&lt;/h2&gt;

&lt;p&gt;Whatever your stack, the &lt;code&gt;paths:&lt;/code&gt; trigger in the workflow file just needs to point at your translation files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;React&lt;/strong&gt; (&lt;a href="https://react.i18next.com/" rel="noopener noreferrer"&gt;react-i18next&lt;/a&gt;): &lt;code&gt;public/locales/{lang}/translation.json&lt;/code&gt; or &lt;code&gt;src/locales/{lang}.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; (&lt;a href="https://next-intl.dev/" rel="noopener noreferrer"&gt;next-intl&lt;/a&gt; or next-i18next): &lt;code&gt;messages/{lang}.json&lt;/code&gt; or &lt;code&gt;public/locales/{lang}/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ruby on Rails&lt;/strong&gt; (&lt;a href="https://guides.rubyonrails.org/i18n.html" rel="noopener noreferrer"&gt;Rails I18n&lt;/a&gt;): &lt;code&gt;config/locales/{lang}.yml&lt;/code&gt; (you'll need a YAML parser that handles nested keys and pluralization)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Django&lt;/strong&gt; (&lt;a href="https://docs.djangoproject.com/en/5.1/topics/i18n/translation/" rel="noopener noreferrer"&gt;gettext&lt;/a&gt;): &lt;code&gt;locale/{lang}/LC_MESSAGES/*.po&lt;/code&gt; (more involved, you need to extract messages with &lt;code&gt;makemessages&lt;/code&gt; first to know which keys were added, then parse and generate PO files)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Approach 2: Using a translation management tool
&lt;/h2&gt;

&lt;p&gt;The DIY approach works for simple projects, but it starts to break down when you need consistent terminology, brand voice, or you're dealing with complex file formats (nested YAML, pluralization rules, ICU messages). This is where dedicated translation tools come in.&lt;/p&gt;

&lt;p&gt;The established players in this space, &lt;a href="https://lokalise.com" rel="noopener noreferrer"&gt;Lokalise&lt;/a&gt;, &lt;a href="https://phrase.com" rel="noopener noreferrer"&gt;Phrase&lt;/a&gt;, &lt;a href="https://crowdin.com" rel="noopener noreferrer"&gt;Crowdin&lt;/a&gt;, and &lt;a href="https://transifex.com" rel="noopener noreferrer"&gt;Transifex&lt;/a&gt;, were built when translations meant coordinating human translators. They're basically project management tools for localization: you sync files to their platform, assign work to translators, manage review workflows, and sync the finished translations back. That model makes sense if you have a dedicated localization team and professional translators. But for modern product teams where dev teams drive the workflow, it's a lot of infrastructure for a problem that doesn't need to be that complicated. (We cover &lt;a href="https://localhero.ai/blog/translation-process-guide" rel="noopener noreferrer"&gt;how to choose between these approaches&lt;/a&gt; in a separate post.)&lt;/p&gt;

&lt;p&gt;Tools like &lt;a href="https://localhero.ai" rel="noopener noreferrer"&gt;Localhero.ai&lt;/a&gt; (disclosure: I built this) take a different approach: automate as much as possible with AI, and build nice tooling for the rest. A CLI plugs directly into your GitHub Actions workflow so translations happen in the PR automatically, and a web-based UI gives you glossary management, style guides, quality checks, and a review interface for when you need to tweak things by hand.&lt;/p&gt;

&lt;p&gt;Here's what the setup looks like using the &lt;a href="https://github.com/localheroai/localhero-action" rel="noopener noreferrer"&gt;Localhero.ai GitHub Action&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/localhero-translate.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Translate with Localhero.ai&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;locales/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;localhero.json'&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;translate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localheroai/localhero-action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.LOCALHERO_API_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The action handles fetching the base branch for comparison, translating missing keys, and committing changes back to the PR. It also skips automatically for draft PRs, bot commits and PRs with a &lt;code&gt;skip-translations&lt;/code&gt; label.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc0wv5o35aij17vx63sht.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc0wv5o35aij17vx63sht.png" alt="Localhero.ai CLI translating in a PR" width="800" height="272"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The translations committed to the PR on GitHub&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcjx9r6d1ew26ddagjji4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcjx9r6d1ew26ddagjji4.png" alt="Translation results in the Localhero.ai dashboard" width="800" height="341"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same translations on Localhero.ai, where you can review and edit&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The difference from the DIY approach is that the translation service maintains your style guides and glossaries, so "checkout" always gets translated consistently across every PR, and your brand voice stays consistent as your team grows. You also get a review view where you can review and edit translations, then push changes back to the PR without touching git. Handy when someone on the team who doesn't live in the terminal wants to tweak a translation before it ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 3: Self-hosted open source
&lt;/h2&gt;

&lt;p&gt;If you need to keep everything on your own infrastructure, maybe for data residency requirements, regulatory compliance or because your content is sensitive, self-hosting the translation platform is an option. This means running your own service that manages glossaries, style guides, and review workflows, while still plugging into GitHub Actions the same way as the other approaches.&lt;/p&gt;

&lt;p&gt;You don't have to self-host the AI models too. Most self-hosted setups still call commercial LLM APIs (OpenAI, Anthropic, etc.) for the actual translations, you just control where the platform data lives. If even the API calls are a concern, tools like &lt;a href="https://github.com/LibreTranslate/LibreTranslate" rel="noopener noreferrer"&gt;LibreTranslate&lt;/a&gt; let you run translation models fully locally, but the quality trade-off is real, especially for less common language pairs.&lt;/p&gt;

&lt;p&gt;The main cost here is setup and maintenance. You're running infrastructure, keeping it updated, and building the tooling that managed services give you out of the box. I think this approach makes the most sense when data control is a hard requirement, not just a preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the right approach
&lt;/h2&gt;

&lt;p&gt;This table compares the three approaches. If you're past that and comparing specific tools, we keep a separate &lt;a href="https://localhero.ai/blog/best-i18n-translation-github-actions" rel="noopener noreferrer"&gt;comparison of the GitHub Actions and hosted tools that translate i18n files&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;DIY (LLM API)&lt;/th&gt;
&lt;th&gt;Dedicated tool&lt;/th&gt;
&lt;th&gt;Self-hosted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;1-2 hours&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;2-4 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Translation quality&lt;/td&gt;
&lt;td&gt;Good for simple text&lt;/td&gt;
&lt;td&gt;Better (context + glossaries)&lt;/td&gt;
&lt;td&gt;Depends on setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brand consistency&lt;/td&gt;
&lt;td&gt;Manual effort&lt;/td&gt;
&lt;td&gt;Built-in (style guides)&lt;/td&gt;
&lt;td&gt;Depends on your tooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;LLM API costs (cents per PR)&lt;/td&gt;
&lt;td&gt;From $29/month&lt;/td&gt;
&lt;td&gt;Infrastructure + API costs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance&lt;/td&gt;
&lt;td&gt;You maintain the script&lt;/td&gt;
&lt;td&gt;Managed&lt;/td&gt;
&lt;td&gt;You maintain everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Side projects, MVPs&lt;/td&gt;
&lt;td&gt;Production teams&lt;/td&gt;
&lt;td&gt;Strict data requirements&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Quick decision guide:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Must your data stay on your own infrastructure?&lt;/strong&gt; → Self-hosted is your path. You can still use commercial LLM APIs for quality, but you'll own the platform setup and maintenance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is this a side project or MVP?&lt;/strong&gt; → The DIY script works, but also check if your tool of choice has a free tier. &lt;a href="https://localhero.ai" rel="noopener noreferrer"&gt;Localhero.ai&lt;/a&gt; for example has a free plan with 250 credits (~4,000 words) per month, which covers most side projects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipping a production app to multiple languages?&lt;/strong&gt; → A dedicated translation tool saves you from maintaining translation scripts, keeps terminology consistent as you scale, and usually gives you a web UI where the whole team can review and edit translations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tips for better automated translations
&lt;/h2&gt;

&lt;p&gt;Regardless of which approach you choose, these practices improve translation quality. I've found them to be consistently useful across different project sizes to ensure brand/quality consistency and developer happiness.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use descriptive, hierarchical translation keys
&lt;/h3&gt;

&lt;p&gt;Keys like &lt;code&gt;btn.submit&lt;/code&gt; don't give the translator (human or AI) enough context. Use hierarchical keys that scope translations clearly. &lt;code&gt;checkout.submit_order_button&lt;/code&gt; tells you exactly where in the app this string lives and what it does. A key like &lt;code&gt;settings.notifications.email_digest_label&lt;/code&gt; is better than &lt;code&gt;notif_email&lt;/code&gt; because the hierarchy provides context that the translator can use. This one change can noticeably improve translation quality, especially with AI translators that use the key name as a signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Only translate what changed
&lt;/h3&gt;

&lt;p&gt;Focus your automation on keys that were added or modified in the current PR, not all missing keys across the project. The DIY script above does this by checking for missing keys. This keeps PRs focused and avoids churning translations that might be in progress elsewhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Review translations in staging
&lt;/h3&gt;

&lt;p&gt;One benefit of PR-based translation workflows is that translations deploy to your staging environment along with the feature. You can see how translated text looks in the actual UI before merging, catching layout issues, text overflow, and awkward phrasing. In my experience, seeing the translation in context catches things that reviewing raw translation files never would. (For more on catching quality issues, see &lt;a href="https://localhero.ai/blog/translation-quality-at-scale" rel="noopener noreferrer"&gt;translation quality at scale&lt;/a&gt;.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Maintain a glossary of product terms
&lt;/h3&gt;

&lt;p&gt;Whether you manage it in a JSON file, a spreadsheet or a tool's glossary feature, having a single source of truth for how your product terms translate prevents inconsistencies like "workspace" being translated three different ways across your app. We have a separate post on &lt;a href="https://localhero.ai/blog/style-guide-glossary-setup" rel="noopener noreferrer"&gt;setting up style guides and glossaries&lt;/a&gt; if you want to go deeper.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't block on perfection
&lt;/h3&gt;

&lt;p&gt;AI translation quality with good context typically gets you 90-95% accuracy for product copy. Ship it, then iterate based on feedback. Your users care more about having features in their language than perfect phrasing in every edge case. For me, this was a mindset shift, but it's the right one.&lt;/p&gt;

&lt;p&gt;For product UI strings, that's usually good enough on its own. Marketing pages or anything with cultural nuance can still benefit from a native speaker reviewing the output. The shift is that human review becomes an occasional quality check, not the bottleneck that blocks every release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the Localhero.ai blog:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://localhero.ai/blog/best-i18n-translation-github-actions" rel="noopener noreferrer"&gt;The best GitHub Actions for i18n translation, compared&lt;/a&gt;: the tool-by-tool comparison, covering open-source actions, hosted services, and TMS integrations&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://localhero.ai/blog/translate-json-yaml-in-ci" rel="noopener noreferrer"&gt;Translating JSON and YAML locale files in CI without a TMS&lt;/a&gt;: the working CI pattern, with code examples for key diffing, placeholder preservation, and validation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://localhero.ai/blog/translate-i18n-with-claude-code-cursor" rel="noopener noreferrer"&gt;Translating your i18n files with Claude Code or Cursor&lt;/a&gt;: making AI coding agents translation-aware&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://localhero.ai/blog/how-to-pick-translation-setup-stack" rel="noopener noreferrer"&gt;How to pick a translation setup for your stack&lt;/a&gt;: decision aid by stack and team shape&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://localhero.ai/blog/translation-process-guide" rel="noopener noreferrer"&gt;Setting Up Your Translation Process&lt;/a&gt;: choosing between in-house, agency, and automated approaches&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://localhero.ai/blog/translation-quality-at-scale" rel="noopener noreferrer"&gt;Translation Quality at Scale&lt;/a&gt;: how to check and maintain quality across thousands of translations&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://localhero.ai/blog/style-guide-glossary-setup" rel="noopener noreferrer"&gt;Setting Up Style Guides &amp;amp; Glossaries&lt;/a&gt;: getting consistent brand voice in automated translations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;External resources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/actions" rel="noopener noreferrer"&gt;GitHub Actions documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.openai.com/docs/api-reference" rel="noopener noreferrer"&gt;OpenAI API reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://react.i18next.com/" rel="noopener noreferrer"&gt;react-i18next docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://next-intl.dev/" rel="noopener noreferrer"&gt;next-intl docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://guides.rubyonrails.org/i18n.html" rel="noopener noreferrer"&gt;Rails I18n guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.djangoproject.com/en/5.1/topics/i18n/translation/" rel="noopener noreferrer"&gt;Django i18n documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>i18n</category>
      <category>webdev</category>
      <category>github</category>
      <category>javascript</category>
    </item>
    <item>
      <title>RAG Retrieval Architectures: When Better Embeddings Stop Helping</title>
      <dc:creator>Arvid Andersson</dc:creator>
      <pubDate>Sun, 16 Aug 2026 18:02:59 +0000</pubDate>
      <link>https://dev.to/arvid_andersson_0a598fa45/rag-retrieval-architectures-when-better-embeddings-stop-helping-3ejm</link>
      <guid>https://dev.to/arvid_andersson_0a598fa45/rag-retrieval-architectures-when-better-embeddings-stop-helping-3ejm</guid>
      <description>&lt;p&gt;Most RAG projects start vector-first: embed the documents, store them, retrieve by similarity. It works in the demo. Then a user searches for an exact thing, a product code, an error number, a specific name, and the system misses it, because vector search ranks by meaning, not by literal tokens. This post is about that failure and the retrieval architectures that fix it, as of June 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure pure vector search hides
&lt;/h2&gt;

&lt;p&gt;An embedding turns text into a point in a space where nearby points mean similar things. That is exactly what you want for "find documents about return policies" and exactly what you do not want for "find SKU-4471". The model encodes "SKU-4471" as something close to other codes that look and read like it, so the literal one a user typed can sit just outside the top results. The same happens with error codes, part numbers, ticket IDs, acronyms, and rare proper nouns.&lt;/p&gt;

&lt;p&gt;This failure stays hidden through testing. Semantic queries work, the demo looks done, and then a user types the one exact thing they expected to match and loses trust in the whole system. It compounds in chat, where users phrase things literally and expect literal matches. A bigger or better embedding model does not fix it, because the problem is not embedding quality, it is that exact-match is a different job from semantic similarity.&lt;/p&gt;

&lt;h2&gt;
  
  
  A retrieval ladder, in order
&lt;/h2&gt;

&lt;p&gt;Climb only as far as your evaluation says you need to. Each rung costs more.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid search.&lt;/strong&gt; Run BM25 (keyword) and vector queries together, merge with rank fusion. Recovers exact tokens without losing semantic recall. The biggest single win for most projects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reranking.&lt;/strong&gt; A cross-encoder re-scores the top candidates against the query and reorders them. Usually the next biggest quality jump after hybrid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query and metadata work.&lt;/strong&gt; Query expansion, metadata filters, and better chunking. Helps when the right chunk exists but is not surfaced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structure-aware retrieval.&lt;/strong&gt; Entity-aware retrieval or a graph layer (GraphRAG) for questions about entities, relationships, and identities that no single chunk answers.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: hybrid search
&lt;/h2&gt;

&lt;p&gt;Hybrid search runs a lexical query (BM25 or full-text) and a vector query, then merges the two result sets, commonly with Reciprocal Rank Fusion. Lexical search nails exact terms and rare tokens; vector search nails paraphrase and meaning. The merge recovers what either alone would miss. This is the rung most projects should reach for first.&lt;/p&gt;

&lt;p&gt;Reciprocal Rank Fusion is worth seeing in full, because it is simpler than it sounds. It ignores the raw scores from each system (which are not comparable anyway) and uses only the rank position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reciprocal_rank_fusion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result_lists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Merge ranked ID lists. k dampens the weight of top positions.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# bm25_hits and vector_hits are ranked lists of document IDs
&lt;/span&gt;&lt;span class="n"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;reciprocal_rank_fusion&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;bm25_hits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vector_hits&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole algorithm. A document ranked well by either system rises; a document ranked well by both rises further. Because it never compares a BM25 score to a cosine similarity, you avoid the score-normalisation problem entirely.&lt;/p&gt;

&lt;p&gt;You can get hybrid two ways. A vector database with native hybrid support handles both signals in one query: &lt;a href="https://infrabase.ai/vector-databases/weaviate" rel="noopener noreferrer"&gt;Weaviate&lt;/a&gt; and &lt;a href="https://infrabase.ai/vector-databases/qdrant" rel="noopener noreferrer"&gt;Qdrant&lt;/a&gt; use BM25-based sparse plus dense vectors, and &lt;a href="https://infrabase.ai/vector-databases/pinecone" rel="noopener noreferrer"&gt;Pinecone&lt;/a&gt; pairs dense vectors with its own sparse model. Or you use a search engine built around hybrid from the start: &lt;a href="https://infrabase.ai/vector-databases/typesense" rel="noopener noreferrer"&gt;Typesense&lt;/a&gt; and &lt;a href="https://infrabase.ai/vector-databases/meilisearch" rel="noopener noreferrer"&gt;Meilisearch&lt;/a&gt; combine full-text and vector search with typo tolerance, and &lt;a href="https://infrabase.ai/vector-databases/azure-ai-search" rel="noopener noreferrer"&gt;Azure AI Search&lt;/a&gt; offers full-text, vector, and hybrid in one managed service. The right choice depends on whether you already run a vector DB or want search-engine ergonomics (faceting, typo tolerance, geo) alongside retrieval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: reranking
&lt;/h2&gt;

&lt;p&gt;Hybrid search widens the net for recall, but the merged ranking is approximate. A reranker fixes the ordering: a cross-encoder reads the query and each candidate together and scores true relevance, then reorders. It is usually the highest-leverage quality change after hybrid, the lever to reach for once recall is good but precision lags.&lt;/p&gt;

&lt;p&gt;Rerankers ship as hosted APIs alongside embeddings. &lt;a href="https://infrabase.ai/inference-apis/jina-ai" rel="noopener noreferrer"&gt;Jina AI&lt;/a&gt; (part of Elastic since October 2025) offers a multilingual reranker next to its embeddings and reader; its reranker weights on Hugging Face are CC-BY-NC licensed, so commercial use goes through the API. &lt;a href="https://infrabase.ai/inference-apis/voyage-ai" rel="noopener noreferrer"&gt;Voyage AI&lt;/a&gt; (now part of MongoDB) provides rerank models with a free tier, focused on retrieval quality. &lt;a href="https://infrabase.ai/inference-apis/cohere" rel="noopener noreferrer"&gt;Cohere&lt;/a&gt; offers a Rerank endpoint. The cost note that matters: rerank only the top candidates (often 50 to 100), not the whole result set, since a cross-encoder is far more expensive per document than a vector lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: query and metadata work
&lt;/h2&gt;

&lt;p&gt;If the right chunk exists but does not surface, the lever moves upstream. Query expansion generates variants of the incoming query (synonyms, expanded acronyms, alternate phrasings) and searches them in parallel, then merges, useful when users type terse or ambiguous queries. Metadata filters narrow the search space before retrieval (by date, source, language, or a field like country) which improves both precision and latency. And chunking strategy decides whether a relevant passage is even retrievable as a unit. These are worth tuning once hybrid and reranking are in place, not before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: structure-aware retrieval
&lt;/h2&gt;

&lt;p&gt;Some questions cannot be answered by ranking chunks at all. "Who is X" or "how is X related to Y" over a corpus where the answer is spread across many passages, with no single chunk acting as a summary, is a retrieval-structure problem. Entity-aware approaches build profiles or summaries per entity; GraphRAG-style approaches build a graph of entities and relationships and traverse it before or alongside chunk retrieval. RAG frameworks like &lt;a href="https://infrabase.ai/frameworks-stacks/llamaindex" rel="noopener noreferrer"&gt;LlamaIndex&lt;/a&gt; and &lt;a href="https://infrabase.ai/frameworks-stacks/haystack" rel="noopener noreferrer"&gt;Haystack&lt;/a&gt; provide building blocks for these patterns. This rung is real work and worth it only when evaluation shows ranking is not the bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the eval set before you climb
&lt;/h2&gt;

&lt;p&gt;The order above is a default, not a prescription. The way to know which rung you actually need is to build a small evaluation set first: a few dozen real queries with the documents that should answer them, then sort the failures into buckets, wrong documents retrieved, right document retrieved but answer missed it, query too vague. Each bucket points at a different rung. Adding features without measuring just moves the failure around. For the generation side of this (catching answers that cite the right chunks but draw the wrong conclusion), see the companion post on &lt;a href="https://infrabase.ai/blog/evaluating-rag-quality-beyond-ragas" rel="noopener noreferrer"&gt;evaluating RAG quality beyond RAGAS&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools by step
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hybrid (native)&lt;/td&gt;
&lt;td&gt;Vector DB with built-in keyword + vector&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://infrabase.ai/vector-databases/weaviate" rel="noopener noreferrer"&gt;Weaviate&lt;/a&gt;, &lt;a href="https://infrabase.ai/vector-databases/qdrant" rel="noopener noreferrer"&gt;Qdrant&lt;/a&gt;, &lt;a href="https://infrabase.ai/vector-databases/pinecone" rel="noopener noreferrer"&gt;Pinecone&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hybrid (search engine)&lt;/td&gt;
&lt;td&gt;Full-text + vector search engine&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://infrabase.ai/vector-databases/typesense" rel="noopener noreferrer"&gt;Typesense&lt;/a&gt;, &lt;a href="https://infrabase.ai/vector-databases/meilisearch" rel="noopener noreferrer"&gt;Meilisearch&lt;/a&gt;, &lt;a href="https://infrabase.ai/vector-databases/azure-ai-search" rel="noopener noreferrer"&gt;Azure AI Search&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reranking&lt;/td&gt;
&lt;td&gt;Cross-encoder re-scoring of candidates&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://infrabase.ai/inference-apis/jina-ai" rel="noopener noreferrer"&gt;Jina AI&lt;/a&gt;, &lt;a href="https://infrabase.ai/inference-apis/voyage-ai" rel="noopener noreferrer"&gt;Voyage AI&lt;/a&gt;, &lt;a href="https://infrabase.ai/inference-apis/cohere" rel="noopener noreferrer"&gt;Cohere&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structure-aware&lt;/td&gt;
&lt;td&gt;Entity and graph retrieval patterns&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://infrabase.ai/frameworks-stacks/llamaindex" rel="noopener noreferrer"&gt;LlamaIndex&lt;/a&gt;, &lt;a href="https://infrabase.ai/frameworks-stacks/haystack" rel="noopener noreferrer"&gt;Haystack&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does my RAG system miss exact matches like SKUs or error codes?
&lt;/h3&gt;

&lt;p&gt;Vector search ranks by semantic similarity, not literal token match. An embedding of "SKU-4471" or "error E-1042" lands near other codes that look similar in meaning, so the exact one a user typed can fall outside the top results. Embeddings are strong at meaning and weak at literal identifiers. The fix is hybrid search: run a keyword (BM25) query alongside the vector query and merge the results, so exact tokens are matched exactly while semantic recall is preserved.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is hybrid search in RAG?
&lt;/h3&gt;

&lt;p&gt;Hybrid search combines lexical search (BM25 or full-text) with vector search and merges the two result sets, usually with Reciprocal Rank Fusion. Lexical search catches exact terms, identifiers, and rare words; vector search catches paraphrases and semantic matches. Together they recover cases that either method alone would miss. Hybrid search is a common default in production RAG systems once they hit real queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a reranker if I already use hybrid search?
&lt;/h3&gt;

&lt;p&gt;Often yes. Hybrid search widens the candidate set for recall, but the top of that merged list is not necessarily ordered by true relevance. A reranker (a cross-encoder model) re-scores the top candidates against the query and reorders them, which usually gives the largest precision gain after hybrid search. It adds latency and cost, so rerank a small candidate set (for example the top 50 to 100), not the whole index.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should I move beyond hybrid search and reranking?
&lt;/h3&gt;

&lt;p&gt;When evaluation shows the failures are no longer about ranking. If the right chunk is never retrieved regardless of method, the problem is upstream: chunking, metadata filters, or missing structure. Questions about entities, relationships, and identities across a corpus often need entity-aware retrieval or a graph layer (GraphRAG) rather than better embeddings. Build an eval set first so you change the layer that is actually failing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is hybrid search worth it for a small RAG project?
&lt;/h3&gt;

&lt;p&gt;It depends on the documents. If your corpus contains identifiers, product names, codes, or rare technical terms that users search for literally, hybrid search pays off quickly. If the content is prose where meaning dominates and exact tokens rarely matter, pure vector search may be enough. The practical test is to build a small eval set with the queries you actually expect, then compare pure vector against hybrid on it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://infrabase.ai/blog/rag-retrieval-architectures" rel="noopener noreferrer"&gt;Infrabase.ai&lt;/a&gt;, where I maintain a hand-verified directory of AI infrastructure tools.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>Translate i18n strings in CI with GitHub Actions: tools compared</title>
      <dc:creator>Arvid Andersson</dc:creator>
      <pubDate>Sun, 14 Jun 2026 22:04:04 +0000</pubDate>
      <link>https://dev.to/arvid_andersson_0a598fa45/translate-i18n-strings-in-ci-with-github-actions-tools-compared-1i8g</link>
      <guid>https://dev.to/arvid_andersson_0a598fa45/translate-i18n-strings-in-ci-with-github-actions-tools-compared-1i8g</guid>
      <description>&lt;p&gt;If your team ships a localized product continuously, translations have a way of becoming the step everything waits on. The feature is done, the PR is green, and then it sits because the German and Japanese copy is blank. The fix a lot of teams reach for is to move translation into CI: when a PR changes the source locale, something translates the new keys and commits them back before merge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm4q9cjv97un6fvfoyj6h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm4q9cjv97un6fvfoyj6h.png" alt="Developer opens a PR, translations generate in CI, the branch merges and deploys, and the product shows up localized" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That loop, source PR in, localized product out, is the whole goal. The rest is which tool runs the middle step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A quick summary of the options:&lt;/strong&gt; the tools worth knowing for this are &lt;a href="https://github.com/i18n-actions/ai-i18n" rel="noopener noreferrer"&gt;ai-i18n&lt;/a&gt; if you want open source and don't mind wiring it up; hosted actions like &lt;a href="https://lingo.dev" rel="noopener noreferrer"&gt;Lingo.dev&lt;/a&gt; for a quick CI hook; the big TMS platforms (&lt;a href="https://crowdin.com" rel="noopener noreferrer"&gt;Crowdin&lt;/a&gt;, &lt;a href="https://lokalise.com" rel="noopener noreferrer"&gt;Lokalise&lt;/a&gt;, &lt;a href="https://www.transifex.com" rel="noopener noreferrer"&gt;Transifex&lt;/a&gt;) if you have a localization team; and &lt;a href="https://localhero.ai" rel="noopener noreferrer"&gt;Localhero.ai&lt;/a&gt; (ours), built around the translate-on-PR workflow with glossary consistency and a review UI for non-developers. What separates them isn't the API call. It's how consistent the output stays over time and whether a non-developer can review it.&lt;/p&gt;

&lt;p&gt;One thing to keep in mind while you compare: getting an LLM to translate one string is easy. Getting &lt;em&gt;consistent, on-brand&lt;/em&gt; translations across hundreds of keys and many languages, PR after PR, so "Workspace" is the same word in German every time and the tone doesn't drift, is not. And whatever you pick, someone usually still wants to review the copy that matters before it ships.&lt;/p&gt;

&lt;p&gt;There are quite a few ways to run that step now, from open-source GitHub Actions you wire up yourself to hosted services that run as an Action. Before the tools themselves, here's what actually separates them.&lt;/p&gt;

&lt;h3&gt;
  
  
  What "good" looks like for CI translation
&lt;/h3&gt;

&lt;p&gt;Most of these tools look similar in a demo. The differences show up a few months in, on a real product with real churn. The things that matter by then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delta translation.&lt;/strong&gt; Translate only the keys that changed in the PR, not the whole file every time. It keeps CI fast and gives the model tighter context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Glossary and tone consistency.&lt;/strong&gt; "Workspace" should be translated the same way in PR #200 as it was in PR #5. It helps when a tool remembers the edits you've made and picks up the patterns already in your codebase, so corrections stick instead of getting re-litigated every PR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A review surface.&lt;/strong&gt; A YAML diff is not a place a PM or native speaker can sanity-check copy. Either you trust the output fully, or you need somewhere to review it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit-back vs PR.&lt;/strong&gt; Committing to the same PR is convenient; opening a separate translation PR is safer for some teams.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  i18n translation tools that run as a GitHub Action
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/i18n-actions/ai-i18n" rel="noopener noreferrer"&gt;ai-i18n&lt;/a&gt; (open source GitHub Action).&lt;/strong&gt; A solid choice if you want full control and don't mind wiring it up. It translates i18n files with an LLM provider you choose (Anthropic, OpenAI, or self-hosted Ollama), uses content hashing to translate only changed strings, and commits results back. It handles XLIFF and JSON (flat and nested). You own the config and the maintenance. Best when you want to tune everything yourself and keep the model choice in your hands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hosted CI-action tools (Lingo.dev and a few others).&lt;/strong&gt; A handful of hosted services run their own GitHub Action that translates on the delta and commits back. &lt;a href="https://lingo.dev" rel="noopener noreferrer"&gt;Lingo.dev&lt;/a&gt; is one of the more visible ones. They cover the basic translate-in-CI loop across a range of CI providers. Where they differ from each other (and from the option below) is less the CI mechanics and more what happens around the strings: how consistent terminology stays over time, and whether a non-developer can review the output. Worth a look if you mainly want the CI hook itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://crowdin.com" rel="noopener noreferrer"&gt;Crowdin&lt;/a&gt; / &lt;a href="https://lokalise.com" rel="noopener noreferrer"&gt;Lokalise&lt;/a&gt; / &lt;a href="https://www.transifex.com" rel="noopener noreferrer"&gt;Transifex&lt;/a&gt; (TMS with GitHub sync).&lt;/strong&gt; The established translation management systems. They sync with GitHub (Crowdin opens a PR when translations update) and are built for teams with dedicated translators: assigning work, tracking who translated what, managing big translation projects. Crowdin has documented support for both react-i18next and LinguiJS. If you have a localization team and a lot of long-form content, this is the category for you. If you're a product team that just wants UI strings translated on every PR, it's usually more platform than the job needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://tolgee.io" rel="noopener noreferrer"&gt;Tolgee&lt;/a&gt;.&lt;/strong&gt; Open source with a hosted tier, an in-context editor, and CI integration. Supports the major JS frameworks (React, Vue, Angular, Svelte, Next) plus JSON and PHP &lt;code&gt;.po&lt;/code&gt;. Good middle ground if you want an open-source core plus a UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://localhero.ai/" rel="noopener noreferrer"&gt;Localhero.ai&lt;/a&gt; (ours).&lt;/strong&gt; Built for product teams that ship continuously. It runs as a GitHub Action, translates the changed i18n keys on each PR, and focuses on the two things that make this hold up on a real product: a glossary and brand voice that stay consistent across PRs via translation memory, and a review UI a PM or native speaker can use instead of reading a YAML diff. Works with react-i18next, LinguiJS, Rails (YAML), and Django (&lt;code&gt;.po&lt;/code&gt;). We're also adding review for translations a PR already includes, not just the ones we generate, so teams bringing their own translations get the same review surface. It's narrower than a full platform on purpose. If you want a focused tool for the translate-on-PR workflow rather than a large platform to configure, it's worth a look. If you want to self-host or stay fully open source, ai-i18n or Tolgee fit better.&lt;/p&gt;

&lt;h3&gt;
  
  
  A rough decision guide
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Want full control, open source, tune it yourself:&lt;/strong&gt; ai-i18n or Tolgee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Have a dedicated localization team and long-form content:&lt;/strong&gt; Crowdin / Lokalise / Transifex.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A product team that ships continuously and wants UI strings translated per-PR, with glossary/brand-voice consistency and a review surface for non-devs:&lt;/strong&gt; try Localhero.ai.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The pattern, regardless of tool
&lt;/h3&gt;

&lt;p&gt;Whatever you pick, the setup that holds up is the same: translate only changed keys, protect placeholders before sending to the model, pass the glossary in context so terms don't drift, validate output before committing, and give someone a place to review brand-sensitive copy. The tool matters less than getting those pieces right.&lt;/p&gt;

&lt;p&gt;The DIY route is genuinely viable, and we wrote up that pattern in detail &lt;a href="https://localhero.ai/blog/translate-json-yaml-in-ci" rel="noopener noreferrer"&gt;here&lt;/a&gt;. The cost that's easy to undercount isn't the first build, it's the upkeep: the placeholder edge case that shows up six months later, the glossary logic, the model swap when prices change, the review surface someone eventually asks for. That work competes with your actual product for engineering time and attention. For some teams that ownership is worth it. For others, the reason to use a tool here is less the features and more not carrying that maintenance yourself.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Automating Translations With Your Coding Agent</title>
      <dc:creator>Arvid Andersson</dc:creator>
      <pubDate>Wed, 08 Apr 2026 13:08:49 +0000</pubDate>
      <link>https://dev.to/arvid_andersson_0a598fa45/automating-translations-with-your-coding-agent-23la</link>
      <guid>https://dev.to/arvid_andersson_0a598fa45/automating-translations-with-your-coding-agent-23la</guid>
      <description>&lt;p&gt;If you've been building features with Claude Code or Cursor, you know the feeling. You're in flow, the agent is writing components, wiring up routes, adding tests. Then you hit the translation strings. What's the German word for "workspace" that we should use? Is it "Arbeitsbereich" or did we decide to keep it as "Workspace"? Who should review this?&lt;/p&gt;

&lt;p&gt;And even when you get the source strings right, the translations themselves are often a separate step. Someone coordinates them, someone else reviews them, the feature sits in a PR waiting. With AI coding agents speeding up development, this gap only gets wider.&lt;/p&gt;

&lt;p&gt;Been working in this space for a while, and the thing that made the biggest difference was simple: just take the translation knowledge your team already has collectively, make it structured, and ensure your coding agent has access to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem isn't translating, it's consistency and brand voice
&lt;/h2&gt;

&lt;p&gt;AI can translate. That's not the hard part any more. The hard part is getting translations that sound like your product, consistently, when multiple people and agents are all contributing to the same codebase.&lt;/p&gt;

&lt;p&gt;Think about what happens without a shared glossary and style guide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Developer A's agent translates "Workspace" as "Arbeitsbereich", Developer B's agent keeps it as "Workspace"&lt;/li&gt;
&lt;li&gt;One PR uses formal German ("Sie"), another uses informal ("du")&lt;/li&gt;
&lt;li&gt;A contractor pastes in ChatGPT translations with a completely different tone&lt;/li&gt;
&lt;li&gt;Your native speaker on the team corrects the same term for the third time this month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app ends up feeling like it was translated by five different people, because it was. Your brand voice gets lost the moment it crosses a language boundary. And every new language multiplies the problem.&lt;/p&gt;

&lt;p&gt;This is why a glossary and style guide matter more than the translation engine. They're the single source of truth that keeps your brand voice consistent regardless of who or what writes the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a glossary and style guide
&lt;/h2&gt;

&lt;p&gt;Before you automate anything, sit down with and make some decisions. These are the things that no one, not your coding agent, not a new team member, not a translation tool, can figure out on their own easily:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Which terms should never be translated?&lt;/strong&gt; Product names, feature names, technical terms your users know in English. "Workspace", "Dashboard", "API" might all stay as-is in German.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which terms have a specific translation?&lt;/strong&gt; Maybe "Save" is always "Speichern", not "Sichern". Maybe "team member" becomes "Teammitglied", not "Mitarbeiter". These are the terms your native speakers have opinions about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's the tone?&lt;/strong&gt; Formal or informal? In German that's the difference between "Sie" and "du", in French between "vous" and "tu". This needs to be consistent across your whole app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any language-specific decisions?&lt;/strong&gt; Scandinavian languages often sound better with a natural spoken register rather than formal written style. Japanese needs the right politeness level. These are things worth writing down once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a conversation with your PM, your content person, the native speakers on your team. It doesn't take long, but the decisions need to be explicit, not just in someone's head. People usually have opinions and examples ready once you ask.&lt;/p&gt;

&lt;p&gt;Once you have this in place, document it. Agents or tools that use it will produce translations that actually sound like your product. Without it, even the best AI will just guess differently every time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1hkevgtddcdzsgla3dw8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1hkevgtddcdzsgla3dw8.png" alt="Translations committed automatically to the PR by a GitHub Action" width="800" height="251"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A nice thing this opens up for is automating. The actual translations into target languages can then happen in CI. For example, a GitHub Action picks up new strings on the PR, translates them with glossary enforcement, and commits them back. By the time someone reviews, all languages are there. Think of it like linting or tests, but for translations.&lt;/p&gt;

&lt;h2&gt;
  
  
  You could wire this up yourself
&lt;/h2&gt;

&lt;p&gt;And for a small project with a few languages, you should. Call an LLM API, write a validation script, keep a glossary file in your repo. There are open source tools like &lt;a href="https://github.com/nicekiwi/i18n-ai-translate" rel="noopener noreferrer"&gt;i18n-ai-translate&lt;/a&gt; and &lt;a href="https://github.com/fkirc/attranslate" rel="noopener noreferrer"&gt;attranslate&lt;/a&gt; that let you bring your own API key and run translations locally. Good starting points.&lt;/p&gt;

&lt;p&gt;But the translation itself is only one piece. The harder parts come later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Consistency over time&lt;/strong&gt;: How do you make sure translations stay consistent as your glossary evolves and new people join the team?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality validation&lt;/strong&gt;: Who catches broken placeholders, wrong formality levels, glossary violations, or tone drift?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compound learning&lt;/strong&gt;: When someone on your team corrects a translation, does that correction inform future translations?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-technical review&lt;/strong&gt;: Can your PM or content person review and edit translations without opening a JSON file?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keeping it running&lt;/strong&gt;: You built it, now you maintain it. Every edge case, every new language, every LLM API change is on your team. That's time spent on translation infrastructure instead of your product.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'm building to solve this
&lt;/h2&gt;

&lt;p&gt;It's not worth rebuilding this infrastructure for every project. &lt;a href="https://localhero.ai" rel="noopener noreferrer"&gt;Localhero.ai&lt;/a&gt; is what I decided to build, a translation service for real product teams, not translators. The core idea is that everything compounds: every translation you ship, every correction someone makes, every glossary term you add feeds into making the next translation better.&lt;/p&gt;

&lt;p&gt;One cool part is how it connects to coding agents. There's an &lt;a href="https://localhero.ai/docs/ai-agents" rel="noopener noreferrer"&gt;agent skill&lt;/a&gt; that loads your glossary, tone settings, and naming conventions dynamically every time your agent works on translation-related code. Install it with &lt;code&gt;npx skills add localheroai/agent-skill&lt;/code&gt; and it works with Claude Code, Cursor, and any agent that reads &lt;a href="https://skills.sh" rel="noopener noreferrer"&gt;skills.sh&lt;/a&gt; skill files. If your PM adds a new term to the glossary or adjusts the style guide, every developer's agent picks that up on the next task. No syncing, no Slack messages.&lt;/p&gt;

&lt;p&gt;On the CI side, a GitHub Action ensures everything is in sync. Every PR that touches locale files gets translated automatically with glossary enforcement, translation memory, and language-specific rules for things like German formality or Scandinavian natural register. Just write the source language and let CI handle the localization.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy638e2hrjemgurm4jndk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy638e2hrjemgurm4jndk.png" alt="Anyone on the team can review and edit translations without touching code" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's a lot more going on under the hood than just calling an LLM. Quality checks catch broken placeholders, glossary violations, and tone drift before anything lands in your PR. Every PR gets a dedicated review page where anyone on the team can edit translations inline or apply suggested fixes. And when someone corrects a translation, that decision feeds back into translation memory, so the system learns from your team's choices over time. It's like working with a translator that actually remembers what you decided last month.&lt;/p&gt;

&lt;p&gt;It works with JSON (React, Next.js, Vue), YAML (Rails), and PO files (Django, Python). The &lt;a href="https://localhero.ai/docs/ai-agents" rel="noopener noreferrer"&gt;agent skill&lt;/a&gt; and &lt;a href="https://github.com/localheroai/cli" rel="noopener noreferrer"&gt;CLI&lt;/a&gt; are open source, and there's a &lt;a href="https://localhero.ai/pricing" rel="noopener noreferrer"&gt;free plan&lt;/a&gt; if you want to try it on a real project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compounding part
&lt;/h2&gt;

&lt;p&gt;The thing I like most about this setup is that it gets better on its own. Every translation you ship, every correction your team makes, every glossary term you add feeds forward. Six months in, the system knows your voice better than any new hire would. That's the part you can't get from a script and an API call.&lt;/p&gt;

&lt;p&gt;If you're shipping in multiple languages and it still feels like a separate workstream, start with the glossary. Get your team's decisions documented, give that context to your agent, and see what happens. And if you want help setting it up, reach out, happy to help.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>ai</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
