Topics

Moving Away from the Google Translate API: Rebuilding Translation with LLMs — Multilingual Support Using Claude API and R2 Differential Caching

  • column

I previously wrote an article about making an Astro SSG site multilingual with the Google Translate API. The approach used the Google Cloud Translation API to translate the static HTML generated after the build, producing a version for each language. For a low-cost, simple multilingual setup, it was quite practical.

However, after running it for a while, I started noticing issues with both SEO and translation quality. For the reasons behind moving away from Google Translate, please see a separate article.

Article: Migrating machine translation from Google Translate to Claude API

This article covers the implementation details: "how exactly was it rebuilt?" I replaced the translation engine from Google Translate with an LLM (Claude API), and also eliminated the manual operational work that had been left over from the previous setup.

Translate with LLM at build time, and cache differences in R2

The basic policy is the same as before: translation is completed at build time (server side). We do not call the Claude API from the browser. What has changed is the translation engine and where the cache is stored.

The build flow works as follows.

npm run build
  ├── fetch-microcms   (microCMSから記事データ取得)
  ├── astro build      (日本語HTMLを生成 → dist/)
  ├── translate        (各ロケールのHTMLを生成 → dist/{locale}/)
  └── update-xml       (sitemap更新)

Roughly speaking, the following steps are performed inside translate (translate-html-llm.mjs).

  1. Download the translation cache (a single JSON file) from Cloudflare R2
  2. Read each HTML file under dist/ with cheerio and extract the text that needs translation
  3. Use the cached translations where available, and send only the uncached items to the Claude API
  4. Replace the text with the translated results and write the output to dist/{locale}/
  5. Merge the newly translated items into the cache and upload it to R2

The two key points are differential translation, which only translates what has changed, and storing that cache in R2.

1. How to make translations that span inline tags sound natural

This was the improvement we wanted most.

For example, suppose the body text contains HTML like this:

<p>私たちは<strong>ウェブアクセシビリティ</strong>を重視しています</p>

If we handled translation the usual way, we would translate three separate fragments: 私たちは / ウェブアクセシビリティ / を重視しています. Japanese and English have different word order, so when the translated fragments are put back in their original positions, the places where <strong> applies can shift, and the sentence can become unnatural. The more inline tags a sentence has, the more it breaks down. This was the biggest problem with the previous approach.

The improvement uses a two-stage approach as follows.

1st: Chunk by block-level elements. Use the contents (innerHTML) of block elements such as p, h1 through h6, li, td, and blockquote as the smallest translation unit, rather than the gaps between tags. Sentences are not split apart.

2nd: Replace inline tags with markers so the whole sentence is passed as a single translation unit. Within a chunk, the <strong> and <a> tags are first replaced with markers such as ....

私たちは[[T1]]ウェブアクセシビリティ[[/T1]]を重視しています

We instruct the LLM: "This is one sentence. Translate it naturally, and reapply the same markers to the words that should be emphasized. Markers may be moved to match the word order of the translation." When the translation comes back, the markers are restored to the original <strong> and <a href="...">. Attributes (such as href and class) are carried over as-is.

As a result, <strong> now attaches to the correct word on the English side as well, and the sentence reads naturally.

2. Differential translation and R2 cache design

Translating everything every time would cost far too much. The previous version also had a cache called translate-cache.json, but this time we revised how the keys are generated and where they are stored.

A mechanism that regenerates translations when the prompt changes

The cache key is built by combining the "original Japanese + locale + the contents of the translation prompt." This way, if the original Japanese changes, only that entry is treated as "not cached," and if the translation instructions or terminology policy (the prompt) change, all entries are automatically treated as uncached and retranslated.

The goal is to prevent the accident of "the prompt was improved, but old translations remain in the cache." In practice, these are hashed with sha256, and the resulting string is used as the key.

The cache contents are structured like this.

{"<sha256のキー>":{"value":"翻訳結果(マーカー込み)","locale":"en","model":"claude-haiku-4-5-20251001","translatedAt":"2026-06-12T..."}}

Moved the cache to R2 and eliminated manual work

This is where we address the leftover issues from last time.

Previously, the cache was managed as a JSON file within the repository. As a result, when an article was added in the CMS and a build was triggered via deploy hook, the server could only see the outdated cache in Git. Out of necessity, we worked around this with an operational rule: after adding an article, build locally and push the updated cache file to Git.

This time, we placed the cache in a single JSON blob on Cloudflare R2. Each build fetches it from R2 and writes it back when finished. As a result, the cache now persists even for builds triggered via webhook, and the manual process of building locally and pushing has been completely eliminated. Simply add an article and push: only the newly added content is translated, and the cache updates automatically.

Translation Priority Order

Terminology inconsistencies (such as variations in how the company name Liberogic is written) were also a challenge last time. This time, we handle them in four stages.

  1. Manual override (data-i18n-key) — Elements tagged with data-i18n-key in the HTML are locked to a handwritten translation you prepare in advance. Neither the LLM nor the dictionary is involved, so you can guarantee that specific strings always read exactly the way you want.
  2. Glossary — Recurring fixed labels, such as navigation items and page titles, have their translations locked in a JSON dictionary. Edit the dictionary and rebuild, and the changes take effect immediately.
  3. R2 cache — If neither of the above applies, the cache is checked.
  4. Claude API — Only items not covered by any of the above are sent to the LLM as a last resort.

Terminology consistency in the body text can't be handled by a point-match dictionary (it doesn't support partial matches), so we loosely align it through terminology hints in the system prompt.

Living with LLM Quirks

Odd machine translations were an issue last time too, and LLMs have their own quirks. Here are a few I've run into in real-world operation.

  • Entire technical sentences stay in English. Over-applying the list of "do not translate" terms such as API, React, and Vue sometimes causes the model to return whole sentences untranslated. I addressed this by emphasizing in the user prompt that output must be in the target language.
  • Marker positions get semantically reversed. The model misjudges which words should be emphasized, so <strong> ends up wrapping the wrong span. Deleting just that entry and retranslating fixes it.
  • Responses get cut off in CJK languages. Kanji consume more tokens per character, so when batches are sent together the output hits the limit and the JSON breaks. I addressed this by raising max_tokens and reducing the number of items per batch.
  • Literal and broken CJK dates. Newlines get mixed in as literal strings in , or extra spaces appear in dates such as 2026 年 05 月 22 日. These are cleaned up in bulk by a post-processing script.

One operational lesson that worked well: retranslating problematic entries one at a time has the highest success rate. When multiple entries are fixed together, the LLM tends to misjudge them the same way because they share the same structure. Slow and steady won.

About Cost

The engine is standardized on Claude Haiku 4.5. Cost is the priority, and if quality issues arise, the approach is to improve the prompt and dictionary first. The system prompt uses prompt caching to reduce the cost of the fixed portion that repeats on every request.

Here are some rough benchmarks from real-world usage.

Details

Cost

Initial full translation for one locale

About $2.5 to $3

Initial full translation for all locales

About $20

Standard deployment (cache hits only)

Nearly $0

Adding one article (translating a few locales)

About $0.01

Daily deploys are nearly free, and adding an article costs only a few yen. The initial rebuild does require a fairly significant cost, but once that hurdle is passed, running costs are actually lighter than before.

Summary

Switching from Google Translate to LLM-based translation resulted in minimal operating costs, and the translation quality has improved considerably.

Using an LLM doesn't make everything perfect automatically. Ongoing, painstaking adjustments are still needed to find and fix quirks as they come up. However, since the points to adjust are now consolidated in easy-to-understand places, the prompt and the glossary, the improvement cycle has become much easier to run.

About the author

Starting out in DTP and moving into the web, he now handles markup, frontend, direction, and accessibility alike. A "master of all trades" who has been versatile since Liberogic's early days and is now a walking encyclopedia of the company. Lately he has been hooked on exploring efficiency through prompts, asking, "Can't we rely more on AI for accessibility work?" Both his skills and his thinking are still evolving.

Ayumu Futamata

IAAP Certified Web Accessibility Specialist (WAS) / Markup Engineer / Frontend Engineer / Web Director

View this staff member's articles

Reliable team structure and responsive support are our strengths

Liberogic's experienced staff actively drive projects forward, which has earned high praise from our clients.
We make sure to properly assign project managers and directors to keep the entire project running smoothly. Our style of allocating resources to the right people, rather than adding full-time commitment unnecessarily, helps prevent cost increases. We are also known for our speed, from understanding the scope of work to preparing and submitting estimates.

Note: Please be advised that we do not actively engage in SES-style on-site staffing assignments.

We support nearly all major project management and chat tools, including Slack, Teams, Redmine, Backlog, Asana, Jira, Notion, Google Workspace, Zoom, and Webex.

Have a web challenge? Let us know.

Case Studies