うちのブログ「ゆるろぐ」、実は最近こっそり英語版を作りました。今回はその裏側、特に「SSRのサイトで多言語化するとAPIコストが爆発しない?」という問題をどう解決したかを書いておきます。
前提:EmDashというCMSを使っている
このブログはEmDashというAstroベースのCMSで動いています。管理画面から記事を書いて、フロントはAstroでサーバーサイドレンダリング(SSR)する構成です。
直感的に操作する感じはWordPressと似ており、「WordPressの精神的後継者」とも言われているようです。
EmDashにはもともと「多言語コンテンツ」の仕組みが用意されていて、記事やページ、メニュー、タクソノミー(カテゴリ・タグ)などをロケールごとに別レコードとして持てるようになっています。translationOfというフィールドで「この英語記事は、この日本語記事の翻訳です」という紐付けをするだけで、同じ記事の別言語版として扱ってもらえます。
microCMSからEmDash + Cloudflareに移行してみた
多言語化、といえばSSGなら簡単なんだけど
一般的に「多言語対応の翻訳をAPIで自動化する」ってなると、SSG(静的サイトジェネレーション)なら話は簡単です。
- ビルド時に1回だけ翻訳APIを叩く
- 結果を静的ファイルとしてキャッシュ
- あとはそのHTMLを配信するだけ
なので、記事数がどれだけ増えても「ビルドのたび」にしかAPIコストが発生しません。頻度もコントロールしやすいし、キャッシュも素直。
でもゆるろぐはSSR。じゃあ毎リクエストAPIを叩くの?
問題はここです。EmDashのCMSページは仕様上、全部SSR(output: "server")でレンダリングされます。静的ビルドでページを作り込む方式ではなく、リクエストが来るたびにCMSのAPI・DBから最新のコンテンツを取ってきて描画する仕組みです。
じゃあ「英語ページを表示するとき、その場でGeminiに翻訳させて出す」という素朴な実装をしたらどうなるか。
- リクエストのたびに翻訳API課金が発生する
- レイテンシも増える(LLMの応答を待つことになる)
- 同じ記事でも毎回微妙に違う翻訳結果が返ってくるかもしれない(再現性がない)
これは完全にアウトです。SSRだからといって「都度APIを叩く」を採用すると、コストもUXも破綻します。
解決策:翻訳を「リクエスト時」から「コンテンツ作成時」に移動する
答えはシンプルで、翻訳をリクエストのタイミングでやらないことです。
具体的には、こういうパイプラインにしました。
- 日本語記事をEmDashの管理画面で書く・公開する
- 翻訳スクリプト(
scripts/translate.mjs)を1回だけ実行する- EmDashのAPIから日本語記事のPortable Text(構造化されたリッチテキスト)を取得
- 翻訳すべきテキスト部分だけを抽出してGemini API(
gemini-3.6-flash)に投げる - 画像・Amazon商品カードなどのブロックは翻訳せずそのままパススルー
- 翻訳結果を新しい英語記事としてEmDashのDBに保存する
translationOfに元の日本語記事のIDを指定して、同じ記事グループの英語版としてリンク- 下書きとして保存されるので、レビューしてから公開できる
つまり、翻訳結果を「その場で生成するもの」ではなく「CMSに保存された、もう一つの言語のコンテンツ」として扱うということです。
こうすると、SSRのリクエスト処理自体は普段の記事表示と何も変わりません。EmDashの通常のロケール対応クエリ(getEmDashEntry(..., { locale: "en" }))で、DBに保存済みの英語コンテンツを引っ張ってくるだけ。Gemini APIへの通信は、記事を翻訳するその1回きりで、閲覧時には一切発生しません。
つまりこういう構図
翻訳が走るタイミング | リクエスト時のAPIコスト | |
|---|---|---|
SSG + 都度翻訳 | ビルド時 | ゼロ(静的ファイル配信) |
SSR + 都度翻訳(NG例) | リクエストごと | 記事×アクセス数ぶん発生 😱 |
SSR + 事前翻訳をDBに保存(今回の方式) | コンテンツ作成時(1回) | ゼロ |
SSGの「ビルド時にまとめてキャッシュ」という発想を、SSRの世界に持ち込むなら「DBに翻訳済みコンテンツとして永続化しておく」がその代替になる、というのが今回の気づきでした。SSRであってもコンテンツそのものは動的に生成され続けるわけではなく、翻訳という「重い処理」だけを事前に済ませておけば、配信自体は普通のCMSページと同じ軽さになります。
ローカルと本番でDBが違って、何度も「あれ消えた!?」をやった話
今回の実装、地味に一番消耗したのがこれでした。EmDashはローカル開発環境と本番環境でそれぞれ別のDBを持っています。当たり前といえば当たり前なんですが、これが原因で「さっきまであった機能が消えてる!」を何度も体験しました。
症状はだいたいこう
- ヘッダーのnavが急に空っぽになる
- footerのCategories一覧が消える
- 記事ページのサイドバーウィジェットが消える
- せっかく作ったローカルのメニュー項目・ウィジェットが元に戻っている
しかも全部「さっきまで動いてたのに」というタイミングで起きるので、最初は「自分がなにか壊した?」と疑いながら原因を探ることになりました。
原因1:本番DBをローカルに取り込む処理(pull.sh)
開発中、「本番の最新データをローカルでも見たい」という理由で、本番DBをローカルに丸ごと取り込むスクリプトを何度か走らせていました。これをやると、ローカルのDBは本番の状態(=英語記事がまだ下書きだったり、公開作業前のメニュー構成だったり)に完全に上書きされます。
つまり「本番ではまだ公開してない機能」をローカルで確認しようとしても、pullした直後は本番と同じ状態に戻っているので、ローカルだけで先に進めていた作業が一時的に見えなくなる、ということが起きていました。
原因2:ローカル管理画面に入り直すたびに走る「シード」処理
もう一つの原因は、ローカルの管理画面にログインし直すための仕組み(開発用のバイパスログイン)が、ついでにseed.json(初期データ定義ファイル)を毎回読み込み直していたことでした。
これが厄介で、
- メニュー項目・ウィジェット → シード側の内容に「完全一致するように」上書きされる(=ローカルで手動追加した分は消える)
- カテゴリ・タグなどのタクソノミー → 「すでに存在するものはスキップ」なので消えない
という、項目によって挙動が違う仕様だったんです。なので「カテゴリは残ってるのに、メニューとウィジェットだけ毎回消える」という一見不可解な現象になっていました。
やった対策
根本的にこの二重構造(pullによる上書き/シードによる上書き)をなくすのは難しかったので、実務的な落とし所として
seed.jsonからデモ用のダミーコンテンツ(メニュー項目・ウィジェット・カテゴリ等)を全部空にする- ローカル環境に入り直すたびに、必要なメニュー・ウィジェットをAPI経由で作り直す
という運用でしのぎました。根治ではなく緩和策ですが、少なくとも「意図しないデモデータで上書きされる」事故は防げるようになりました。
まとめ
- EmDashはもともとロケールごとにコンテンツを持てる多言語対応のCMS
- SSGなら「ビルド時に翻訳→キャッシュ」で済むが、SSRだと「都度翻訳」は課金地獄になる
- 解決策は、翻訳をリクエスト時ではなくコンテンツ作成時に1回だけ実行し、結果を通常のCMSコンテンツとしてDBに保存すること
- 使った翻訳エンジンはGemini API(
gemini-3.6-flash) - ただしCMSを多言語化するときは、翻訳フローだけ整えれば終わりではなく、「ローカルと本番でDBが分かれていること」自体が新しい罠を生むことも忘れずに
- 特に「本番にはまだない状態をローカルで先取りして作業する」タイプの開発は、環境同期の仕組み(pull・シード・キャッシュなど)の副作用に振り回されやすい
今回の教訓は、「翻訳をどのタイミングでやるか」と「環境同期がいつ・何を上書きするか」、この2つのタイミング設計さえ事前に把握しておけば、SSRでも運用コストと事故に振り回されずに多言語対応ができます。
皆さんのサイトでも多言語化いかがでしょうか!
マークアップを中心に、JavaScriptやReact、Next.jsを使ってフロントエンドの開発をやっています。自分が関わったサイトが無事に公開されると嬉しいです!趣味はギターを弾くこと。猫と焼き芋が好き🐱🍠
ひらっち
フロントエンドエンジニア / 2022年入社