コーポレートサイトのホスティングを Firebase から Vercel へ、CMS を Contentful から microCMS へ移行し、あわせてサイト全体を日英対応にしました。作業は Claude Code と進めています。
移行そのものより、実データに当ててみて初めて分かった制約のほうが役に立つと思うので、詰まった点を中心に書きます。
移行前後の構成
Before | After | |
|---|---|---|
ホスティング | Firebase Hosting → Cloud Functions v2 に全リクエストを rewrite | Vercel Functions(hnd1) |
CMS | Contentful(GraphQL / 本文は Markdown) | microCMS(REST / 本文はリッチエディタ) |
言語 | 日本語のみ | 日本語 + 英語( |
デプロイ | GitHub Actions から firebase deploy | Vercel の Git 連携 |
Vercel 移行:独自パイプラインが丸ごと消えた
移行前は Remix のビルド成果物を手で移動していました。build/client/assets を public/ にコピーし、build/server を functions/ にコピーしてから Cloud Functions としてデプロイする、という構成です。functions/ には本体と別の package.json があり、依存を二重管理していました。
@vercel/remix の Vite preset を入れたら、この一式が不要になりました。差分は 11,664 行削除・706 行追加。ビルドスクリプトも sass:build && remix vite:build だけになっています。
環境変数をビルド時からランタイムへ
それまで loader の中で import.meta.env.VITE_CONTENTFUL_* を読んでいました。Vite の VITE_ プレフィックスはビルド時にバンドルへ埋め込まれる方式なので、環境変数を変えるたびに再ビルドが必要になります。クライアント側から参照すればそのまま公開されてしまう点も気持ちが悪い。
これを process.env のランタイム参照に変え、あわせて各ページに散らばっていた「Space ID とトークンを引数で渡す」重複をクライアントモジュール側に集約しました。
詰まった点:サーバー専用モジュールがクライアントに混入する
日英でページ実装を共有するため、ページコンポーネントを app/features/ に切り出しました。このとき loader とコンポーネントを同じファイルに置いたらビルドが落ちます。
[commonjs--resolver] Server-only module referenced by client
'~/client.server' imported by 'app/features/article-list.tsx'Remix が loader を取り除いてくれるのはルートモジュールだけで、そこから import した普通のモジュールは対象外です。loader を *.server.ts に分離し、ルートファイルからそれぞれを import する形にして解決しました。
Contentful → microCMS:API 設計と実データの検証
Hobby プランの API 数 5 に収める
Contentful には blogPost / work / person / tag / category の 5 種類がありました。そのまま移すと上限ちょうどで余白がありません。
そこでコードを調べたところ、tag(43 種・98 付与)と category はサイト上で一度も使われていませんでした。tag はデータを失わないよう記事のテキストフィールドにカンマ区切りで持たせ、category は移行対象から外しました。結果 3 つに収まり、2 枠を残せています。
contentId に slug をそのまま使う
microCMS のリスト型コンテンツは contentId を任意の文字列で指定できます。Contentful の slug をそのまま使えば /articles/:slug の URL が変わりません。
ただし contentId は英小文字・数字・ハイフン・アンダースコアのみです。100 件中 1 件だけ大文字を含む slug があり、そこは小文字化して loader で 301 リダイレクトを入れました。
公開日は専用フィールドを作らなくてよかった
当初は publishDate フィールドを作るつもりでしたが、microCMS のシステムフィールド publishedAt は書き込み API から直接指定できます。Contentful の公開日をそのまま移せたので、フィールドを 1 つ節約できました。
いっぽう createdAt は書き込めません('createdAt' is unexpected key.)。管理画面の作成日は移行実施日のままになりますが、表示・並び順に使うのは publishedAt なので実害はありませんでした。
リッチエディタ化は「壊れ方を先に測る」
Contentful の本文は Markdown 文字列で、サイト側が marked で HTML 化していました。microCMS ではリッチエディタ(richEditorV2)に変えたかったのですが、リッチエディタは許可した要素以外を保存時に落とします。
そこで移行前に、全 100 件の Markdown を marked で変換し、許可リストに無い要素が残るコンテンツを機械的に洗い出しました。
- コードブロック 187 個・インラインコード 281 個 →
codeBlockとcodeを許可すれば保持できる - 見出しは h1〜h4 を使用 →
headerOne〜headerFiveでカバー、h6 が 1 件だけ範囲外 - コードフェンスの外に生 HTML を含む記事が 5 件 → 編集時に崩れる可能性があるため、別途手直しする課題として起票
「たぶん大丈夫」で進めずに、対象を 5 件に特定できたのが大きかったです。
画像 107 点と 5MB の壁
Contentful のアセットは 312 点ありましたが、実際に参照されているのは 107 点でした。本文中に埋め込まれた画像 URL も収集して移行対象にしています。
途中で 1 件だけ request body too large で失敗しました。microCMS のメディアアップロードは 1 ファイル 5MB までで、5.4MB の写真が引っかかったためです。上限を超えるものだけ Contentful の画像 API(?w=2400&q=80)で縮小してから取得するようにして通しました。
詰まった点:PUT は新規作成専用
移行スクリプトは何度でも安全に再実行できるようにしたかったので PUT /api/v1/{endpoint}/{contentId} を使っていました。ところが 2 回目でこうなります。
400 {"message":"Content is already exists. If you want update, please use PATCH request."}microCMS の PUT は新規作成専用でした。既存なら PATCH にフォールバックする実装に変えて、冪等性を確保しています。
日英対応
英語ページは /en 配下に置き、root の loader が URL からロケールを判定して全ページへ配る形にしました。各コンポーネントは useLocale() / useT() で参照するだけです。
文言は共通辞書とページ別コピーに分けています。ナビ・カード・フォームなどページをまたぐものは共通辞書へ、1 ページでしか使わない本文はページごとのモジュールへ。こうしないとページが増えるたびに共通辞書が肥大化します。
canonical は片方に寄せない
日英を別 URL で持つとき、canonical を片方に寄せてはいけません。同一内容の重複ではなく別言語の対応版なので、寄せられた側がインデックスから外れて英語圏からの流入を捨てることになります。
正しくは、各言語版が自分自身を canonical に指し、両者の関係を hreflang で示す形です。
<link rel="canonical" href="https://gift-tech.co.jp/en/about" />
<link rel="alternate" hrefLang="ja" href="https://gift-tech.co.jp/about" />
<link rel="alternate" hrefLang="en" href="https://gift-tech.co.jp/en/about" />
<link rel="alternate" hrefLang="x-default" href="https://gift-tech.co.jp/about" />本文 27 万文字の英訳
記事 78 件・実績 22 件の本文は 27 万文字ありました。これは Claude のサブエージェントを 12 並列で走らせて英訳しています。
翻訳より大事なのは壊れていないことの確認で、生成後に全 100 ファイルを機械検証しました。コードフェンス数の一致(改変ゼロ)、本文の欠落なし、差し替わった URL 14 件を実際に HTTP チェックしてリンク切れゼロ。人名の英語表記が 1 箇所ゆれていたのもこの検証で見つかりました。
それでも機械翻訳なので、公開前の人によるレビューは必要という前提は変えていません。
やってみて
今回いちばん効いたのは、コードを書くことより検証を仕組みにしたことでした。
- 移行スクリプトを冪等にする(途中で失敗しても再実行できる)
- 実データを全件流して、壊れ方を件数で把握してから決める
- 「英語ページに日本語文字が 1 文字も残っていないこと」のように、判定を機械で書く
この 3 つがあると、AI に任せる範囲を広げても結果を確認できます。逆にこれが無いと、動いているように見えて実は壊れている状態に気づけません。実際、記事一覧に見慣れない ID が出て焦った場面がありましたが、検証スクリプトを書いたら本文中の外部リンクの誤検出だと 1 分で判明しました。