一部の情報は非公開です。アクセスキーをお持ちの方は完全版をご覧いただけます。
新エネルギー・建設機材のレンタル企業
建設・再エネ機材レンタル企業のサイトと CMS を全面再構築
テンプレートに縛られた Wix サイトを、自作の CMS とプリレンダリングされたフロントへ作り直しました。データベース設計からデプロイまで一人で担当しています。
概要
クライアントは、太陽光発電・蓄電池・保安機材のレンタルおよび販売を手がける日本の企業です。旧サイトは Wix 上に構築されていたため、商品詳細ページはテンプレートの制約を受け、業務に合わせて自由に組み立てることができませんでした。
新サイトでは既存の CMS を一切使わず、Nuxt と Astro で管理画面とフロントをゼロから構築しました。商品詳細ページを組み立てる力を運用担当者に渡しつつ、訪問者側では静的サイトの速度を保つことが目標です。データベース設計からデプロイ設定まで、すべて一人で担当しました。
アーキテクチャ
2 つの Cloudflare Worker が同じ D1 データベースを共有し、CMS 側が書き込み、フロント側は読み取り専用です。メディアは R2 に置き、ブラウザは CMS が発行した署名付き URL で直接アップロードします。Worker を経由しないので、リクエストサイズと CPU 時間の制限を回避できます。
公式の AWS SDK は Cloudflare ランタイムではモジュール読み込みの時点で落ち、エラーメッセージは本当の原因をまったく指していませんでした。fetch だけで実装された署名ライブラリに差し替えて解決し、この調査結果はソースコードのコメントに残してあります。次の人が同じ穴を踏まないためです。
フロントの 19 ページはビルド時にプリレンダリングし、商品詳細のセクションコンポーネントは Vue で JavaScript ゼロのサーバーサイドレンダリングにしています。操作が必要な絞り込みだけを Solid で island として書き、2 つのフレームワークのコンパイル境界はディレクトリの取り決めで分離しています。
技術的な難所
商品詳細ページのセクションビルダー
商品詳細ページでは、決められたビジュアル規定をそのまま再現する必要がありました。セクション 15 種類、バリエーション 41 個です。難しいのは数ではなく、エディタのフォーム・エディタのプレビュー・本番のレンダリングという 3 箇所がそれぞれ勝手にずれていくことでした。どれか 1 つのバリエーションにフィールドを足すたび、3 箇所すべてを直さなければなりません。
解決策は、セクションを独立した workspace パッケージに切り出し、1 つの zod スキーマを唯一の契約にすることでした。管理画面のバリデーションとフロントのレンダリングが同じ型を読みます。同じ Vue コンポーネント群が CMS ではリアルタイムプレビューを、フロントでは JavaScript ゼロのレンダリングを担当するので、実装が二重になる余地がそもそもありません。
データの形が異なるバリエーションは、フィールドをすべて任意にするのではなく、判別可能なユニオンで表現しています。こうすると入力漏れは保存時のバリデーションで弾かれ、訪問者がページを開いてから実行時エラーとして現れることがありません。

プリレンダリングと未公開の変更
全ページのプリレンダリングには避けられない副作用があります。編集者が管理画面で内容を直しても、次のビルドが走るまでサイトは何も変わりません。一人で運用する現場では、コンソールへ手動でビルドを打ちに行くことを誰も覚えていられません。
解決策は、「未公開」をシステムの一級の状態として扱うことでした。訪問者向けの表示に影響する書き込みが成功したら、単一行の状態テーブルにフラグを立てます。COALESCE を使い、記録するのは最後の変更ではなく最初の変更の時刻にしてあるので、管理画面の上部に「どれだけ溜まっているか」を出せます。
公開ボタンは Deploy Hook をサーバー側でプロキシしていて、シークレットを含む URL はブラウザへ一切渡しません。保存のたびに自動ビルドする方式は選びませんでした。10 個のフィールドを続けて直せばビルドが 10 回走るからで、人が確認したうえでまとめて公開する形にしています。

画像を消す前の全テーブル参照スキャン
メディアのテーブルは 4 箇所から参照されますが、本当の外部キーはその一部だけです。JSON 列に入った素の id や、セクションが持つオブジェクトキーは、データベースの制約では守られません。1 枚消すとき、データベースが止められるのは一種類だけで、見落とせば公開中の画像が 404 になり、しかも取り返しがつきません。
- 外部キー列は id をそのまま照合する
- 数値の配列は json_each で展開して 1 件ずつ突き合わせる
- オブジェクトの配列は json_extract でメディア id を取り出す
- 形の定まらない設定列は、JSON として妥当かを確かめてから展開する
- セクション本文はオブジェクトキーを保持しているため、エスケープ込みの部分一致に切り替える
- 該当があれば 409 を返して使用箇所を列挙し、どのコンテンツがその画像を使っているかを運用側に伝える
部分一致は厳密ではありませんが、意図的な取捨です。誤検知は取り消せる一度の拒否で済むのに対し、見落としは消えたファイルが戻らないことを意味します。以前の実装は決め打ちの属性名を厳密に探していましたが、その属性はコードベースのどこにも書かれたことがなく、スキャンは一度、静かに機能しなくなっていました。
これらの参照を中間テーブルへ正規化する道は選びませんでした。教科書的にはそれが正解です。ただし正規化すると、コンテンツを保存するたびに同期処理が付いて回ります。メディアの削除は頻度の低い操作なので、一度の全件スキャンで長期的な同期の負担を買い取るほうが割に合います。
公開サイト