このサイト(AppVillage)は、認証もデータベースも画像ストレージも、バックエンドをSupabase 1つでまかなっています。

外部サービス全部見せの記事では構成の全体像を紹介しましたが、今回はその中核であるSupabaseを1本まるごと掘り下げます。テーブル設計もRLSポリシーも実物を載せますし、後半では本番のTTFBが3秒になった事件— Next.jsとSupabaseの組み合わせで踏みやすい罠 — の顛末を実測値つきで書きます。

この記事で分かること

  • 個人開発サイトの実際のSupabase構成(認証・DB・ストレージ)
  • たった3テーブルで回っている実物のデータベース設計とRLSポリシー
  • cookies()がISRを無効化してTTFBが約3秒になった原因と、約0.3秒に戻すまでの対処
  • 無料枠とProプランの現実的な使い分け

結論: 「1つで完結」が最大の価値。ただしNext.jsとの間に罠が1つ

  • Supabaseを選ぶ最大の理由は、認証・PostgreSQL・ストレージが1サービスで完結すること。個人開発は「管理するものの数」がそのまま負担になる
  • データベースは3テーブル(profiles / apps / favorites)から始めて、今もそのまま。小さく設計して困っていない
  • ただしNext.js App Routerとの組み合わせには「cookies()を読んだページはISRが死ぬ」という構造的な罠がある。うちはこれで公開ページのTTFBが約3秒になり、「Cookieを読まない3つ目のクライアント」を作って約0.3秒に戻した

AppVillageでのSupabaseの役割

訪問者 → Vercel (Next.js) ─┬─ 認証: Supabase Auth (GitHub / Google OAuth)
                            ├─ データ: PostgreSQL (アプリ投稿・プロフィール・お気に入り)
                            └─ 画像: Supabase Storage (アイコン・スクリーンショット)

ユーザー認証、投稿データ、アップロード画像 — 「データまわり」は全部Supabaseです。バックエンドのコードを1行も自前でホストしていません。

テーブル設計: 3テーブルで足りている

アプリ紹介サイトと聞くともっと複雑に見えるかもしれませんが、コアは3つだけです。

テーブル 役割 ポイント
profiles ユーザープロフィール Supabase Authのauth.usersと同じUUIDを主キーに
apps アプリ投稿 メタデータ・スクリーンショット配列・ストアリンク
favorites お気に入り user_id×app_idのユニーク制約だけのシンプルな中間テーブル

素のPostgreSQLなので、リレーションを含む取得も自然に書けます。

// アプリ詳細 + 作者プロフィールを1クエリで
const { data } = await supabase
  .from('apps')
  .select(`
    *,
    profiles (
      display_name,
      github_username
    )
  `)
  .eq('id', appId)
  .single()

最初から凝ったスキーマを設計するより、「必要になったテーブルを必要になったときに足す」方が個人開発では回ります。実際、リリースから今まで骨格はこの3テーブルのままです。

RLS: アプリのif文ではなく、DBの門番に守らせる

Supabaseの設計で一番大事なのがRow Level Security(RLS)です。「誰がどの行を読み書きできるか」をアプリのコードではなくデータベース側のポリシーで強制します。実物はこうです。

-- 誰でも閲覧できる
CREATE POLICY "Apps are viewable by everyone"
ON apps FOR SELECT
USING (true);

-- 投稿は「自分が作者のデータ」しか作れない
CREATE POLICY "Authenticated users can insert apps"
ON apps FOR INSERT
WITH CHECK (auth.uid() = author_id);

-- 更新・削除も自分のアプリのみ
CREATE POLICY "Users can update own apps"
ON apps FOR UPDATE
USING (auth.uid() = author_id);

フロントのコードにバグがあっても、他人の投稿を書き換えるSQLはDBの時点で通らない。個人開発は自分しかレビュアーがいないので、「最後の砦がアプリの外にある」安心感は大きいです。

定番のハマりどころも1つ: RLSを有効にしてポリシーを書き忘れると、クエリはエラーではなく空の結果を返します。「データが消えた!?」と焦ったら、まずRLSポリシーを疑ってください。

認証: OAuthだけに絞る

ログインはGitHubとGoogleのOAuthのみ。メール+パスワード認証は最初から作っていません。

  • パスワードリセットや確認メールの運用が丸ごと不要になる
  • 個人開発者向けサイトという特性上、ユーザーはほぼ確実にGitHubかGoogleのアカウントを持っている

コールバックはSupabaseのSSRヘルパー(@supabase/ssr)を使い、サーバー側でコードをセッションに交換してからクライアントに戻す2段構えです。ここは公式ドキュメントの型どおりに作るのが一番安全でした。

事件: 本番のTTFBが3秒になった

さて、本題の罠の話です。

8月のある日、トップページの表示がやけに遅いことに気づきました。計測するとTTFB(最初の1バイトが返るまでの時間)が約3秒。コードは重くない。DBも軽い。原因は意外なところにありました。

原因: cookies()がISRを殺していた

サーバーコンポーネントでのデータ取得に、認証対応のサーバー用クライアントを使っていました。

// supabase-server.ts — 認証が必要な処理用
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()  // ← これが問題
  // ...
}

Next.jsはcookies()を呼んだページを「リクエストごとに中身が変わるページ」とみなして動的レンダリングに固定します。すると、ページにrevalidate(ISR)を書いていても無効。つまり —

  • 意図: 「1時間キャッシュした静的HTMLを一瞬で返す」
  • 現実: 「毎リクエスト、サーバーでレンダリングしてDBに問い合わせる」

トップページは誰が見ても同じ内容なのに、全訪問者分のレンダリングが毎回走っていたわけです。

対処: Cookieを読まない「3つ目のクライアント」

Supabaseクライアントは普通「ブラウザ用」「サーバー用」の2つを作りますが、うちは3つ目を足しました。

// supabase-public.ts — 公開データ専用。cookies() を呼ばない
import { createClient } from '@supabase/supabase-js'

export function createPublicClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

そして公開ページのデータ取得をこちらに差し替え。import時にエイリアスすれば、ページ側のコードはほぼ無変更で済みます。

// 呼び出し側の変更は実質この1行
import { createPublicClient as createClient } from '@/lib/supabase-public'

結果、ISRが復活してTTFBは約3秒→約0.3秒。10倍の改善です。

使い分けの原則

クライアント 用途
ブラウザ用(supabase.ts) Client Componentでの操作(投稿・お気に入り・認証状態)
サーバー認証用(supabase-server.ts) auth callbackなど、本当に認証が必要なサーバー処理のみ
公開データ用(supabase-public.ts) 一覧・詳細など、誰が見ても同じ公開ページ(ISR維持)

原則はシンプルで、「ログイン状態に応じたUI(お気に入りボタンやユーザーメニュー)はClient Componentの責務にして、サーバーレンダリングでは認証を参照しない」。公開データのページからCookieへの依存を消せば、静的キャッシュは自然に効きます。

ストレージ: 画像はSupabase、配信の最適化は要検討

アプリのアイコンやスクリーンショットは、投稿時にSupabase Storageへアップロードし、公開URLで配信しています。DBと同じ管理画面で見られるのは地味に便利です。

1点だけ注意があるとすれば、この公開URLをnext/imageの最適化に通すかどうか。うちはVercel無料枠のクォータを使い切って画像が消える障害を起こしたので、外部ストレージ配信の画像は最適化を通さない構成にしています。

料金の実感

  • 無料枠で持てるのは2プロジェクトまで。このサイト単体なら無料枠で十分収まる規模です
  • 筆者は複数アプリのバックエンドを運用していて上限を超えたため、Proプラン($25/月)を使っています。課金理由は性能ではなくプロジェクト数でした
  • 個人開発を続けるなら「3つ目のサービスを作った時点で$25/月が始まる」前提で考えておくのがおすすめです

Supabase×Next.jsのチェックリスト

  • 公開ページのデータ取得に、cookies()を読むクライアントを使っていないか
  • revalidateを書いたページが、ビルド出力で本当に静的(○/●)になっているか
  • RLSを有効にした全テーブルに、必要なポリシーを書いたか(空の結果はポリシー漏れのサイン)
  • 認証まわりは公式ドキュメントの型どおりに(独自工夫はバグの温床)
  • ストレージの公開URLを画像最適化に通す場合は、変換回数の上限を確認したか

まとめ

  • Supabaseの価値は認証・DB・ストレージが1つで完結すること。個人開発の「管理コスト」を最小にできる
  • 設計は小さくでいい。3テーブル+RLSで実サービスは回る
  • 最大の罠はNext.jsとの組み合わせ: cookies()を読んだページはISRが死ぬ。公開データはCookie非依存のクライアントに分離する(TTFB 3秒→0.3秒の実測差)
  • 無料枠は2プロジェクトまで。続けるほどPro($25/月)は前提になる

「バックエンドを自分で持たない」構成は、個人開発の体力配分を根本から変えてくれます。データまわりで消耗している人は、一度この構成を試してみてください。