メインコンテンツへスキップ
← 記事一覧に戻る
·開発·13 min read

Supabase 1プロジェクト × 複数アプリ構成 — RLSで安全に共用する方法

recipe-aiSupabaseRLSコスト削減個人開発

Supabaseの無料プランには「2プロジェクトまで」という制限がある。個人開発で複数のアプリを作り始めると、この枠はあっという間に埋まる。

3つ目のアプリを作る段階で有料プランに上げるか、既存プロジェクトを削除するか--どちらも個人開発者にとっては痛い選択だ。

この記事では、筆者が実際に採用した「1つのSupabaseプロジェクトで複数アプリを安全に動かす方法」を、RLSの実装コード付きで解説する。KOBO(SaaS)とrecipe-ai(YouTube料理動画レシピ抽出AI)を同居させた実例がベースになっている。

この記事でわかること:

  • Supabase Freeプランの制約と、1プロジェクト共用のメリット
  • テーブル分離 + RLSによる安全な複数アプリ構成パターン
  • recipes テーブルのDDLとRLSポリシーの具体的な実装
  • ブラウザ/サーバー両対応のSupabaseクライアント設計
  • 認証を複数アプリで共有する際の注意点

なぜ1プロジェクトで複数アプリなのか

Freeプランの制約

SupabaseのFreeプランで作れるプロジェクトは2つまで。しかも、非アクティブなプロジェクトは7日で自動停止される。

筆者の場合、既にKOBO(SaaS)で1枠を使っていた。新しくrecipe-aiを始めるにあたり、選択肢は3つあった。

  1. 2枠目を使う(将来の3つ目に困る)
  2. Pro プラン(月$25)にアップグレードする
  3. KOBOと同じプロジェクトに相乗りする

コスト最優先の判断

個人開発では月の固定費を限りなくゼロに近づけることが生存戦略になる。月$25は年間$300。まだ1円も稼いでいないアプリに払う額ではない。

3番の「相乗り」を選べば追加コストはゼロ。しかもFreeプランの2枠目が温存されるので、将来の別プロジェクトにも備えられる。

筆者の運営費は月5,000円以下に抑えるルールにしている。Supabase、Vercel、Cloudflareすべて無料枠で運用中。「有料プランに上げる前に、無料枠の中で工夫できないか」を常に考える癖がついた。

設計パターン: テーブル分離 + RLS

1プロジェクトに複数アプリを同居させる設計は、実はシンプルだ。

アプリごとにテーブルを追加して、RLS(Row Level Security)で分離する。

Supabase プロジェクト (amtwwscvhwkfdrqimgqm)
├── auth.users         ... 全アプリ共通の認証
├── KOBO のテーブル群   ... profiles, products, subscriptions, ...
└── recipe-ai のテーブル ... recipes

各テーブルにはRLSポリシーを設定し、auth.uid() でログインユーザー自身のデータにしかアクセスできないようにする。アプリAのテーブルにアプリBからアクセスする経路はそもそも存在しないし、仮にクエリを投げても RLS が弾く。

重要なのは、テーブル同士に直接の依存関係を持たせないことだ。KOBOの profiles テーブルとrecipe-aiの recipes テーブルをJOINするような設計はしない。共通するのは auth.users への外部キーだけ。

実装: recipes テーブルの追加

マイグレーションSQL全文

recipe-ai で追加したマイグレーションファイルの全文がこちらだ。

-- recipe-ai: recipes テーブル定義
-- 実行先: KOBO 共用 Supabase プロジェクト (amtwwscvhwkfdrqimgqm)
-- RLS で user_id ベースに分離されるため KOBO の他テーブルとは衝突しない

create table if not exists recipes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  source_url text not null,
  source_video_title text,
  data jsonb not null,
  created_at timestamptz not null default now()
);

create index if not exists recipes_user_id_created_at_idx
  on recipes (user_id, created_at desc);

alter table recipes enable row level security;

ポイントは3つ。

  1. user_idauth.users(id) への外部キー -- Supabaseの認証システムと直結する。ON DELETE CASCADE でユーザー削除時にレシピも消える
  2. data jsonb -- レシピの材料・手順・コツなどをJSON構造で保存。スキーマの柔軟性を確保しつつ、テーブルは1つで済む
  3. 複合インデックス (user_id, created_at DESC) -- 「自分のレシピを新しい順に取得する」クエリを高速化

RLSポリシーの書き方

RLSは4つのポリシーで構成する。SELECT / INSERT / UPDATE / DELETE それぞれに「自分のデータだけ」というルールを設定する。

-- 自分のレシピだけ select/insert/update/delete できる
drop policy if exists "users select own recipes" on recipes;
create policy "users select own recipes" on recipes
  for select using (auth.uid() = user_id);

drop policy if exists "users insert own recipes" on recipes;
create policy "users insert own recipes" on recipes
  for insert with check (auth.uid() = user_id);

drop policy if exists "users update own recipes" on recipes;
create policy "users update own recipes" on recipes
  for update using (auth.uid() = user_id);

drop policy if exists "users delete own recipes" on recipes;
create policy "users delete own recipes" on recipes
  for delete using (auth.uid() = user_id);

auth.uid() はSupabaseが提供する組み込み関数で、現在ログインしているユーザーのUUIDを返す。ログインしていなければ null を返すので、未認証ユーザーは何も見えない。

drop policy if exists を先に書いているのは、マイグレーションの冪等性を確保するため。何度実行しても同じ結果になる。

共用クライアントの設計

Supabaseへの接続コードは、アプリごとに書き方が変わるわけではない。環境変数に同じプロジェクトURLとキーを設定すれば、同じクライアントコードがそのまま使える。

ブラウザ用クライアント(client.ts)

import { createBrowserClient } from "@supabase/ssr";

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

サーバー用クライアント(server.ts)

import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {}
        },
      },
    }
  );
}

KOBOでもrecipe-aiでも、このコードは全く同じだ。違うのは.envファイルに書く値ではなく、アプリ側が叩くテーブル名だけ。KOBOは profilessubscriptions を、recipe-aiは recipes をクエリする。RLSがあるので、間違ったテーブルを叩いても他アプリのデータが漏れることはない。

管理者用クライアント

サーバーサイドでRLSをバイパスしたい場合(管理画面やバッチ処理など)は、Service Role Keyを使う。

export async function createAdminClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {}
        },
      },
    }
  );
}

SUPABASE_SERVICE_ROLE_KEY はRLSを完全にバイパスするため、絶対にブラウザに露出させてはいけないNEXT_PUBLIC_ プレフィックスを付けないこと。サーバーサイド専用だ。

環境変数の管理

.env.local に書く変数は、KOBOもrecipe-aiも同じ値になる。

NEXT_PUBLIC_SUPABASE_URL=https://amtwwscvhwkfdrqimgqm.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...(anon key)
SUPABASE_SERVICE_ROLE_KEY=eyJ...(service role key)

Vercelにデプロイする場合は、各プロジェクトのEnvironment Variablesに同じ値を設定する。管理すべきSupabaseプロジェクトが1つなので、キーのローテーション時も1箇所の変更で済む。

認証の共有

1つのAuthで複数アプリのユーザーを管理

Supabaseの認証は auth.users テーブルに集約される。1プロジェクトに1つのAuthシステムなので、KOBOで登録したユーザーはrecipe-aiでもそのままログインできる。

これは意図的な設計だ。ユーザーにとって「同じ開発者のサービスなのにアカウントが別」というのは不便でしかない。1つのアカウントで複数サービスを使えるのは、ユーザー体験としても正しい。

対応する認証方式:

  • メール/パスワード
  • Google OAuth

Google OAuth設定の注意点

Google OAuthのリダイレクトURLは、Supabase側で1つ設定すれば全アプリで共有される(SupabaseがOAuthフローを中継するため)。

ただし、各アプリのコールバックURLはアプリごとに異なる。Supabaseの Authentication > URL Configuration で、以下のように各アプリのURLをSite URLまたはRedirect URLsに追加する必要がある。

https://kobo.example.com/api/auth/callback
https://recipe-ai.example.com/api/auth/callback
http://localhost:3000/api/auth/callback   # KOBO ローカル
http://localhost:3001/api/auth/callback   # recipe-ai ローカル

ローカル開発でポートを分けているのもポイントだ。KOBOはport 3000、recipe-aiはport 3001で起動するので、同時に開発できる。

この構成の注意点

テーブル名の衝突に注意

1プロジェクトに全テーブルが同居するので、テーブル名が被ると困る。対策は2つ。

  1. 命名規則を決める: アプリ固有のテーブルにはプレフィックスを付ける(例: recipe_categories)。ただし共通性の高い名前(users, profiles)は最初のアプリが確保する
  2. スキーマを分ける: PostgreSQLの CREATE SCHEMA を使えば名前空間を完全に分離できる。ただしSupabase Freeプランではカスタムスキーマの扱いにクセがあるので、テーブル数が少ないうちは命名規則で十分

recipe-aiの場合、追加したテーブルは recipes の1つだけ。KOBOに同名のテーブルは存在しないので、命名規則だけで問題なく運用できている。

マイグレーション管理の方法

各アプリのリポジトリに supabase/migrations/ ディレクトリを持ち、アプリ固有のDDLを管理する。実行先は同じSupabaseプロジェクトだが、ファイルとしてはアプリごとに分離されている。

KOBO/
  supabase/migrations/
    001_profiles.sql
    002_products.sql
    ...

recipe-ai/
  supabase/migrations/
    001_recipes.sql

Supabase CLIの supabase db push は同じプロジェクトに対して実行する。冪等なSQLを書いておけば(CREATE TABLE IF NOT EXISTSDROP POLICY IF EXISTS)、どのアプリから実行しても衝突しない。

まとめ

Supabase 1プロジェクトで複数アプリを動かす構成は、テーブル追加 + RLS設定だけで実現できる。認証も共有されるのでユーザー体験も損なわない。Freeプランの枠を温存しつつ月の追加コストはゼロ。個人開発のコスト戦略として、最初に検討する価値がある。

recipe-ai を試す(無料)

この記事で作っている YouTube 料理レシピ抽出アプリ本体。動画 URL を貼るだけで AI がレシピに変換。月 5 本まで無料。

関連記事

Next.js + Supabase 個人開発入門2026

無料枠だけでSaaSを作る構成と実装手順を解説

Supabase RLS ペイウォールパターン

RLSを使った有料コンテンツの出し分け実装

150秒を1.9秒超えて全滅した話

無料インフラ3段階移行と月0円運用の全記録

Claude Crew Lab Free — 毎月の実験記録をメールで

Claude Code × 個人開発のリアルな事故・発見・SaaS アイデアを毎月第1月曜にお届け。登録で「収益化チェックリスト 15 項目」を無料プレゼント。

Lab Free 登録(月1回・無料)

この記事が役に立ったらシェア