Previously, I wrote an article about multi-language implementation of an Astro SSG site using Google Translate API. It was a system that used Google Cloud Translation API to translate static HTML after builds and generate versions in each language. As a cost-effective, lightweight multi-language approach, it was practical enough.
However, after running it for a while, I started noticing issues on both SEO and translation quality fronts. For the story of "why we stopped using Google Translate," check out a separate article.
Article: Migrating machine translation from Google Translate to Claude API
This article covers the implementation details of "how we actually rebuilt it." We swapped out the translation engine from Google Translate to an LLM (Claude API), and at the same time, we eliminated the manual operational overhead that was left from the previous approach.
Translate with LLM at build time, differential cache on R2
The basic approach is the same as before — translation is completed at build time (server-side). We don't call Claude API from the browser. What's different is the translation engine and where the cache is stored.
The build flow looks like this.
npm run build
├── fetch-microcms (microCMSから記事データ取得)
├── astro build (日本語HTMLを生成 → dist/)
├── translate (各ロケールのHTMLを生成 → dist/{locale}/)
└── update-xml (sitemap更新)
What translate does internally (in translate-html-llm.mjs) happens roughly in this order.
- Download translation cache (a single JSON file) from Cloudflare R2
- Load each HTML file under
dist/with cheerio and extract text that needs translation - Use cache if available; only send missing items to the Claude API
- Replace text with translation results and write to
dist/{locale}/ - Merge newly translated content into cache and upload to R2
The key points are two-fold: differential translation ("translate only what changed") and storing that cache in R2.
① How to make translations natural across inline tags
This was what we most wanted to improve this time.
For example, suppose the body text contains HTML like this.
<p>私たちは<strong>ウェブアクセシビリティ</strong>を重視しています</p>
With standard translation processing, we would end up translating web accessibility as a priority as three separate fragments. Since Japanese and English have different word orders, putting the translated fragments back in their original positions causes <strong> to attach in the wrong place, or the sentence becomes unnatural altogether. The more inline tags there are, the worse the problem gets. This was the biggest frustration with the old system.
The improvement approach works in two stages:
First: Chunk by block element. The minimum translation unit is not "between tags," but the entire innerHTML of block elements like p, h1–h6, li, td, and blockquote. Sentences are not split.
Second: Replace inline tags with markers and pass the entire sentence as a single translation unit. Within a chunk, <strong> and <a> are temporarily replaced with markers like ....
私たちは[[T1]]ウェブアクセシビリティ[[/T1]]を重視しています
Instruct the LLM: "This is one sentence. Translate it naturally, and you may reattach the same markers to words that should be emphasized. Markers may shift position to match your target word order." When the translation comes back, restore the markers to their original <strong>, <a href="...">, etc. Attributes like href and class are carried through unchanged.
This way, <strong>emphasis now applies to the correct word in English too, and the sentence reads naturally.
② Differential Translation and R2 Cache Design
Translating everything from scratch every time would drain any budget. In the previous project, we used a cache called translate-cache.json, but this time we reconsidered how to generate keys and where to store the cache.
Regenerate translations when the prompt changes
Cache keys are built by combining the original Japanese, the locale, and the content of the translation prompt. With this approach, if the source Japanese changes, only that entry is re-translated; if the translation instructions or terminology policy (prompt) changes, all entries are automatically treated as "not in cache" and retranslated.
The goal is to prevent the accident where an old translation lingers in the cache even after you improve the prompt. In practice, we hash these into a string key using sha256.
Here is the structure of the cache contents.
{"<sha256のキー>":{"value":"翻訳結果(マーカー込み)","locale":"en","model":"claude-haiku-4-5-20251001","translatedAt":"2026-06-12T..."}}
Placed cache on R2 and eliminated manual work
This is where we resolved the backlog from last time.
Previously, cache was managed as JSON files within the repository. When we added articles via the CMS and triggered builds through deploy hooks, the server could only see the old cache on Git. As a workaround, we had to follow an operational rule: "After adding articles, build locally, then push the updated cache files to Git."
This time, we placed cache as a single JSON blob on Cloudflare R2. On every build, we fetch from R2 and write back when done. Now cache persists even during webhook-triggered builds, and the manual local build-and-push workflow has completely disappeared. When we add articles and push, only the new ones get translated, and the cache updates automatically.
Translation priorities
Inconsistent terminology—like variations in how we write the company name Liberogic—was a concern last time too. This time we handle it in four tiers.
- Manual override (
data-i18n-key) ― Elements marked withdata-i18n-keyon the HTML side are locked to hand-written translations prepared in advance. We don't rely on the LLM or dictionary—this approach lets us say "this phrase must use this exact translation." - Glossary ― Recurring fixed labels like navigation items and page titles get their translations locked in a JSON dictionary. Updating the glossary and rebuilding applies changes instantly.
- R2 cache ― If neither of the above applies, we check the cache.
- Claude API ― Only items not covered by the above go to the LLM as a final step.
Terminology consistency throughout the body text can't be fully captured by a simple dictionary (which doesn't support partial matches), so we use loose alignment through terminology hints in the system prompt.
Living with LLM quirks
Machine translation producing strange translations isn't new, but LLMs have their own peculiarities. Here are a few that emerged from real-world operation.
- Technical sentences remain entirely in English. The "do-not-translate" list for terms like API, React, and Vue is sometimes over-applied, causing the entire sentence to be returned in English. We handle this by strongly reinforcing in the user prompt that output must be in the target locale language.
- Marker positions flip to reverse the intended meaning. The LLM sometimes misjudges which phrase should be emphasized, causing
<strong>markers to wrap the wrong range. Deleting and retranslating just that entry fixes it. - CJK language responses cut off mid-stream. Kanji consumes more tokens per character, so batching large volumes causes output to hit the limit and breaks JSON. We fix this by raising
max_tokensand reducing batch size. - Literal
characters and CJK date corruption. Line breaks leak in as the string, or dates likeMay 22, 2026pick up extra spaces. We handle these with post-processing scripts.
One operational insight that proved valuable: fixing errors one entry at a time and retranslating yields the highest success rate. Trying to fix multiple issues at once tends to cause the LLM to systematically misapply the same correction pattern. Slow and steady wins the race.
Cost considerations
We standardize on Claude Haiku 4.5. The priority is cost; when quality issues arise, we first improve the prompt and glossary. We use prompt caching in the system prompt to compress the cost of the same fixed sections on every call.
Here's what the actual benchmark looks like.
Details | Cost |
|---|---|
Initial full translation for one locale | Approximately $2.50-3.00 |
Initial full translation for all locales | Approximately $20 |
Standard deployment (cache hits only) | Nearly $0 |
Adding one article (a few translations) | Approximately $0.01 |
Daily deployments cost almost nothing, and adding articles runs just 1–10 yen each. The initial rebuild required significant upfront investment, but once we crossed that hurdle, ongoing costs actually became lighter than before.
Summary
Switching from Google Translate to LLM-based translation didn't require substantial ongoing costs, and translation quality improved significantly.
LLM translation doesn't mean everything runs perfectly on autopilot. We still do steady, methodical tuning to catch and fix quirks. But because adjustments are concentrated in two clear places—prompts and dictionaries—the improvement cycle became much easier to iterate.
A "master of technique" who jumped from DTP into the web world and, before he knew it, mastered markup, frontend, direction, and accessibility. Active across multiple domains since Liberogic's early days, he's now a walking encyclopedia within the company. Recently, he's been diving deep into prompt-driven efficiency optimization, wondering "Can we rely more on AI for accessibility compliance?" Both his technology and thinking continue to evolve.
Ayumu Futamata
IAAP Certified Web Accessibility Specialist (WAS) / Markup Engineer / Frontend Engineer / Web Director