このブログサイトの技術スタックとインフラ構成について
なぜこの構成にしたのか
このプロジェクトには最初から2つの制約を課していました。
- 月額を最低限に抑えること。 個人の日記帳にサーバー代を払い続けたくはありません。
- いつでも引っ越せること。 無料枠が消えたり、規約が変わったりしても、データごと別の場所へ移せる状態を保つ。
この2つが、スタック選定のあらゆる場面で判断基準になりました。
スタック全体像
| レイヤー | 採用したもの | ひとことメモ |
|---|---|---|
| フレームワーク | Next.js 16(App Router / Turbopack)+ React 19 + TypeScript strict | web・console 両方 |
| モノレポ | pnpm workspaces + Turborepo | 共有パッケージはTSソースのまま参照 |
| データベース | PostgreSQL 17(Supabase)+ Drizzle ORM | スキーマをコードで管理、SQLマイグレーションはgit管理 |
| 多言語化 | next-intl v4 | パスにロケールを前置(/ja/...)、Accept-Language判定 |
| 本文パイプライン | unified / remark / rehype + 自作ショートコードレジストリ | rehype-sanitize の許可リストでサニタイズ |
| 認証 | Supabase Auth(GitHub OAuth) | 管理者1名をメールアドレスで許可リスト管理 |
| オブジェクトストレージ | Supabase Storage(画像・PDF・バックアップ)+ Cloudflare R2(音声・動画) | R2は下り課金ゼロ |
| 機械翻訳 | Google Cloud Translation API v3(REST直叩き) | gRPC SDKは使わず google-auth-library で署名 |
| 画像処理 | sharp | リサイズ・EXIF除去・サムネ生成 |
| メディア再生 | ネイティブ <audio>/<video> の上に自作Reactプレイヤー |
カルーセルはヘッドレスな Embla |
| スタイリング | 素のCSS + CSS Modules | UIフレームワークなし、モバイルファースト |
| CI/運用 | GitHub Actions | 週次の自動 pg_dump バックアップ |
| テスト | Vitest(ユニット)+ 実インフラ相手の統合スクリプト | |
| ホスティング | Vercel(1リポジトリから2プロジェクト) | Hobbyプラン |
以下、判断のポイントになった箇所を掘り下げます。
なぜモノレポ(pnpm + Turborepo)なのか
公開サイトと管理コンソールは、別々のアプリでありながら、同じデータ構造と同じ本文レンダリングを共有します。この「共有」をどう扱うかが最初の分岐点でした。
答えは pnpm workspaces + Turborepo によるモノレポです。共有したいものはすべて packages/ に置きます。
neo-diary/
├── apps/
│ ├── web/ # 公開サイト(ポート3000)
│ └── console/ # 管理コンソール(ポート3001)
└── packages/
├── db/ # Drizzleスキーマ、マイグレーション、型付きクライアント、ロケール定数
└── content/ # Markdownパイプライン、ショートコードレジストリ、共有型
肝は、共有パッケージをビルド済みの成果物ではなく、TypeScriptのソースのまま参照していることです(Next.jsの transpilePackages を使用)。これにより、packages/content を直したら両方のアプリに即反映され、ビルドの中間ステップを挟まずに済みます。
「両方のアプリが必要とするもの(スキーマ型・Markdown描画・ロケール定数)は必ず packages/ に置き、apps/ の中で二重に持たない」── このルールを最初に決めたことで、公開サイトのプレビューとコンソールのプレビューが構造的に一致するという前述の性質が自然に手に入りました。
なぜ Supabase なのか
「月ほぼ0円」と「ポータブル」の両方を満たす必要がありました。
- 中身はただのPostgreSQL。 ここが決め手です。Supabaseは独自DBではなく素のPostgreSQL 17。だから、いざとなれば
pg_dump/pg_restoreで別のPostgresへそっくり移せます。ベンダーロックインの度合いが低い。 - 無料枠でDB・ストレージ・認証がまとめて揃う。 個人ブログの規模なら実質ゼロ円。
スキーマはコードで管理(Drizzle ORM)
テーブル定義はTypeScriptで書き、そこからSQLマイグレーションを生成してgitにコミットします。スキーマそのものが、いつでもゼロから再現できるという状態を保つためです。これは後述する「災害復旧」の3本柱の1本でもあります。
スキーマ設計で気に入っているのは、assets テーブルの kind をあえてただのtext型にし、種類ごとの細かい情報を metadata(JSONB)に逃がしたことです。おかげで、後から音声・動画という新しいアセット種を足したときもマイグレーションは不要でした。「後で拡張する余地」をスキーマの型設計そのものに埋め込んだ形です。
なぜストレージを2つに分けたのか(Supabase Storage + Cloudflare R2)
- 画像・PDF・バックアップ → Supabase Storage。 DBと同じ場所にまとまっていて扱いやすい。
- 音声・動画 → Cloudflare R2。 理由はただ一つ、下り(egress)課金がゼロだから。動画は容量も転送量も大きくなりがちで、下り課金があるストレージに置くと、アクセスが増えたときに費用が読めなくなります。R2は10GBまで無料で、外に出す転送量に課金されません。
さらに音声・動画はブラウザから直接R2へアップロードします。署名付きのPUT URL(presigned URL、短命・単一オブジェクト・対象記事にスコープ限定)を発行し、大きなファイルがアプリのサーバーを一切通らないようにしました。副作用として、サーバーレスのリクエストボディ上限も回避できます。
なぜ Google翻訳を「REST直叩き」なのか
機械翻訳には Google Cloud Translation API v3 を使っています。ただし公式のgRPC SDKは入れず、RESTエンドポイントを fetch で直接叩き、google-auth-library でサービスアカウント署名だけ行う構成にしました。
理由は依存を軽くするためです。必要なのは「テキストを投げて訳を受け取る」だけ。そのために重いSDK一式を抱え込むより、認証だけ借りて薄く叩くほうがビルドも軽く、挙動も追いやすい。無料枠(月50万文字未満)に収まる個人利用なら、これで十分です。翻訳前には文字数をカウントして表示し、無料枠を意識できるようにしています。

なぜ UIフレームワークを使わないのか(素のCSS + CSS Modules)
TailwindもMUIも使っていません。スタイリングは素のCSSをCSS Modulesで書いています。
個人のブログという小さな範囲であれば、UIフレームワークの学習コストと吐き出すCSSの重さより、自分で書いたほうが軽くて、見通しがよくて、楽しいという判断です。カルーセルのようにロジックが重い部品だけ、ヘッドレスなライブラリ(Embla Carousel)を借りています ── 見た目は借りず、挙動だけ借りる方針です。
ちなみにトップページ背景の「夜の警察署」も、画像ではなくすべてCSSで描いています。他人のドット絵を1ピクセルも借りずに、それらしい雰囲気だけを立ち上げる ── これも「素のCSSで書く楽しさ」の産物です。
インフラ全体図
┌────────────── pnpm + Turborepo モノレポ ───────────────────┐
│ apps/web(公開サイト) apps/console(管理コンソール) │
│ │ │ │
│ └──────────┬───────────────┘ │
│ packages/content(Markdown+ショートコード描画) │
│ packages/db(Drizzleスキーマ・型・ロケール) │
└──────────────────────┬────────────────────────────────────┘
│
┌────────────────┼───────────────────┐
▼ ▼ ▼
Supabase Cloudflare R2 Google Cloud
・Postgres 17 ・音声/動画 ・Translation API v3
・Storage ・presign直接アップ ・サービスアカウント認証
・Auth(GitHub) ・下り課金ゼロ
▲
│ 週次 pg_dump
GitHub Actions(cron)
一番怖い瞬間は、セーフルームを出る直前だ。