Topics

Building a multilingual blog with EmDash — how to reduce translation API costs even with SSR

  • column

Our blog Yuru Log actually got an English version quietly launched recently. This time I'm writing about what went on behind the scenes, particularly how we solved the question: "Won't API costs explode when you add multiple languages to an SSR site?"

Context: We use a CMS called EmDash

This blog runs on EmDash, an Astro-based CMS. We write articles through the admin panel, and the frontend renders with Astro using server-side rendering (SSR).

The intuitive operation feels similar to WordPress, and it's sometimes called the "spiritual successor to WordPress."

EmDash already has a built-in system for "multilingual content," allowing articles, pages, menus, and taxonomies (categories and tags) to exist as separate records per locale. By using a translationOf field to link "this English article is a translation of this Japanese article," it treats them as alternate language versions of the same content.

Migrating from microCMS to EmDash + Cloudflare

When it comes to multilingual support, SSG makes it simple

Generally speaking, when you automate multilingual translation via API, SSG (Static Site Generation) makes things straightforward.

  • You hit the translation API just once at build time
  • Cache results as static files
  • Then all you need to do is serve that HTML

So no matter how many articles you add, API costs only occur at build time. The frequency is easy to control, and caching just works.

But Yuru Log uses SSR. So does that mean we call the API on every request?

That's the issue. By default, EmDash's CMS pages are rendered entirely as SSR (output: "server"). Instead of pre-building pages at compile time, the system fetches the latest content from the CMS API and database on each incoming request and renders it.

So what if we took a straightforward approach: "When displaying the English page, have Gemini translate it on the spot and send it back"?

  • Translation API charges incur with every request
  • Latency increases (you have to wait for the LLM response)
  • The same article might return slightly different translations each time (no consistency)

This is completely unworkable. Adopting "call the API every time" just because it's SSR breaks both costs and user experience.

Solution: Move translation from "request time" to "content creation time"

The answer is simple: don't translate at request time.

Specifically, here's the pipeline we implemented.

  1. Write and publish a Japanese article in the EmDash management console
  2. Run the translation script (scripts/translate.mjs) once
    • Fetch the Japanese article's Portable Text (structured rich text) from the EmDash API
    • Extract only the text portions that need translation and send them to the Gemini API (gemini-3.6-flash)
    • Pass through blocks like images and Amazon product cards without translating them
  3. Save the translation result as a new English article in the EmDash database
    • Specify the original Japanese article's ID in translationOf to link it as the English version of the same article group
    • It's saved as a draft, so you can review it before publishing.

In other words, the translation result is treated not as "something generated on the fly" but as "content in another language that's already been saved in the CMS."

This way, the SSR request handling itself is no different from normal article display. It simply uses EmDash's standard locale query (getEmDashEntry(..., { locale: "en" })) to fetch the English content already stored in the database. Communication with the Gemini API happens only once when the article is translated, and never occurs when the page is viewed.

In other words, here's how it works

When translation runs

API cost per request

SSG + on-demand translation

At build time

Zero (static file delivery)

SSR + on-demand translation (not recommended)

Per request

Generated once per article × page view 😱

SSR + pre-translated content saved to database (this approach)

Once at content creation

Zero

The key insight from this implementation was recognizing that SSG's approach of "caching everything at build time" can be adapted to SSR by "persisting pre-translated content in the database." Even with SSR, the content itself doesn't need to be regenerated on every request—by handling the expensive translation process upfront, the actual delivery becomes as lightweight as serving a standard CMS page.

When local and production databases differ: a story of "Wait, it disappeared!?"

This was the part of the implementation that drained the most energy. EmDash uses separate databases for local development and production environments. While that's normal, it meant repeatedly encountering "The feature that was there a moment ago is gone!" We experienced this frustration more times than we'd like to admit.

The symptoms typically looked like this

  • The header navigation suddenly becomes empty
  • Footer Categories list disappears
  • Sidebar widgets on article pages disappear
  • Locally created menu items and widgets revert to their original state

What made it particularly frustrating is that these issues occurred at moments when everything had been working fine just moments before, so initially I found myself wondering "Did I break something?" while searching for the cause.

Root cause 1: The process of pulling production DB to local environment (pull.sh)

During development, I ran a script several times that pulled the entire production database into the local environment because I wanted to see the latest production data locally. When this happens, the local database is completely overwritten with the production state—meaning English articles are still in draft, menu structures are in their pre-release configuration, and so on.

In other words, even when trying to verify a feature that hasn't been publicly released in production, immediately after pulling it's back to the production state, so work that had progressed further locally temporarily becomes invisible.

Root cause 2: The 'seed' process that runs every time you log into the local admin panel

The other cause was that the mechanism for logging back into the local admin panel (a development bypass login) was reloading seed.json (the initial data definition file) every time.

This was particularly troublesome because,

  • Menu items and widgets → overwritten to match seed data exactly (meaning any manually added local content gets deleted)
  • Taxonomies like categories and tags → not deleted because existing ones are skipped

This meant the behavior varied depending on the data type. As a result, we got a counterintuitive situation where categories persisted while menu items and widgets alone disappeared each time.

What we did

Completely eliminating this dual-layer structure (pull-based overwrites and seed-based overwrites) was difficult, so as a practical workaround we:

  • Emptied all demo dummy content (menu items, widgets, categories, etc.) from seed.json
  • Recreated necessary menus and widgets via API each time the local environment was reinitialized

This wasn't a permanent fix, just a mitigation strategy, but it at least prevented accidents where unintended demo data overwrote local changes.

Summary

  • EmDash is a headless CMS that natively supports multiple languages with per-locale content
  • With SSG you can translate once at build time and cache it, but with SSR, translating on-demand becomes prohibitively expensive
  • The solution is to run translation only once at content creation time, not at request time, and store the result in the database as regular CMS content.
  • The translation engine used was Gemini API (gemini-3.6-flash).
  • However, when localizing a CMS to multiple languages, remember that setting up the translation flow alone is not enough—the fact that local and production databases are separate creates new pitfalls.
  • In particular, development workflows that involve preparing content locally before it exists in production are prone to side effects from environment synchronization mechanisms (pulls, seeding, caching, and so on).

The key takeaway from this experience is that if you plan ahead for two things—when to run translations and when/what environment synchronization will overwrite—you can support multiple languages in SSR without being derailed by operational costs and incidents.

How about adding multilingual support to your site as well?

About the author of this article

I focus on frontend development with markup, JavaScript, React, and Next.js. I'm always happy when a site I've worked on goes live successfully! My hobbies are playing guitar, and I love cats and roasted sweet potatoes 🐱🍠

Hiraicchi

Frontend Engineer / Joined 2022

Read this staff member's article

Reliable team structure and responsive project management are our strengths

At Liberogic, our experienced staff actively drive projects forward, earning high praise from clients.
We carefully assign project managers and directors to ensure smooth project execution across all phases. We prevent unnecessary cost increases from over-commitment by deploying resources strategically, and we're known for speed in project understanding, estimation, and delivery.

* Please note that we do not actively pursue on-site SES-style staffing arrangements.

You can use virtually all major project management and chat tools, including Slack, Teams, Redmine, Backlog, Asana, Jira, Notion, Google Workspace, Zoom, Webex, and more.

Tell us about your web concerns.

Case Studies