SupabaseはPostgreSQLベースのBaaSで、Next.js App Routerと組み合わせることでDB・認証・ストレージをすぐに使える。RLS(Row Level Security)によるDB側アクセス制御と@supabase/ssrによるSSR対応が設計の核心。
@supabase/ssrを使うことでサーバー/クライアント両方でセッションを共有できるSERVICE_ROLE_KEYの管理とRLSの設定漏れが最大のセキュリティリスクFirebaseのRDB版。PostgreSQLベースでSQL・ORM・Realtimeが使える
SupabaseはPostgreSQLをベースにしたオープンソースのBaaS(Backend as a Service)。FirebaseがNoSQL(Firestore)なのに対し、SupabaseはRDBなのでSQLがそのまま書けて、JOINやトランザクション、スキーマ定義などRDBの恩恵をフルに受けられる。
Djangoでいうと「ORM + django-allauth + S3の組み合わせを設定だけで使える」感覚に近い。
Next.js App
↕ supabase-js(SDK)
Supabase
├── PostgreSQL(DB)
├── Auth(JWT)
├── Storage(S3互換)
└── Realtime(WebSocket)
💡 ポイント: SupabaseのDBは本物のPostgreSQL。PrismaやDrizzleなどのORMとも組み合わせられる。
用語
| 用語 | 説明 |
|---|---|
| BaaS | Backend as a Service。バックエンド機能をクラウドサービスとして提供 |
| RDB | リレーショナルDB。SQLで操作できる |
| Realtime | DBの変更をWebSocketでリアルタイム購読できる機能 |
技術選定の判断軸は「SQLが必要か」「オープンソースかどうか」の2点に集約される
| 観点 | Supabase | Firebase |
|---|---|---|
| DBの種類 | PostgreSQL(RDB) | Firestore(NoSQL) |
| クエリ | SQL・ORM(Prisma/Drizzle) | 専用クエリAPI |
| JOIN・トランザクション | ✅ ネイティブ対応 | ❌ 非対応 |
| オープンソース | ✅ セルフホスト可能 | ❌ Google依存 |
| リアルタイム | ✅ WebSocket | ✅ WebSocket |
| 認証 | ✅ JWT | ✅ Firebase Auth |
| ストレージ | ✅ S3互換 | ✅ Cloud Storage |
| 料金モデル | 無料枠あり・従量課金 | 無料枠あり・従量課金 |
| 向いているケース | 複雑なリレーション・SQL資産の活用 | 柔軟なスキーマ・Googleサービス連携 |
💡 ポイント: JOINやトランザクションが必要なアプリ、既存のSQL知識を活かしたい場合はSupabaseが優位。スキーマが頻繁に変わるプロトタイプやモバイルファーストならFirebaseも選択肢になる。
Free プランは個人・検証用途に十分だが、本番運用は Pro($25/月)以上を検討する
| 項目 | Free | Pro($25/月) | Team($599/月) |
|---|---|---|---|
| DBサイズ | 500MB | 8GB(超過分$0.125/GB) | 無制限 |
| ストレージ | 1GB | 100GB(超過分$0.021/GB) | 無制限 |
| 帯域幅 | 5GB/月 | 250GB/月 | 無制限 |
| 月間アクティブユーザー | 50,000 | 100,000 | 無制限 |
| バックアップ | なし | 毎日 | ポイントインタイムリカバリ |
| SLA | なし | 99.9% | 99.9% |
| カスタムドメイン | ❌ | ✅ | ✅ |
| ブランチ機能 | ❌ | ✅ | ✅ |
💡 ポイント: Freeプランは2週間非アクティブでプロジェクトが一時停止される(Pro以上は対象外)。本番環境では必ずProプラン以上を選択すること。
参考
プロジェクト作成で専用のPostgreSQL DBとAPIエンドポイントが自動払い出しされる
Project Settings > API からキーを控えるNEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJh...
SUPABASE_SERVICE_ROLE_KEY=eyJh... # ⚠️ NEXT_PUBLIC_ をつけない
💡 ポイント:
SERVICE_ROLE_KEYは絶対にNEXT_PUBLIC_をつけてはいけない。RLSを完全にバイパスしてしまうため、クライアントに漏れると全データが危険にさらされる。
anon keyが公開しても安全な理由
anon keyは「認証されていない匿名ユーザー」として動作するキー。RLSのポリシーによってアクセスが制限される前提で公開しても安全。ただしRLSを無効にしたままだとanon keyでも全データにアクセスできるため、RLSの正しい設定が大前提。
用語
| 用語 | 説明 |
|---|---|
| anon key | 匿名ユーザー用キー。RLSポリシーで制限される前提で公開可 |
| service_role key | RLSをバイパスするキー。サーバーサイドのみで使用 |
@supabase/ssrパッケージをインストールし、環境変数と型定義を整備するのが出発点
# パッケージのインストール
npm install @supabase/supabase-js @supabase/ssr
インストール後、.env.localに環境変数を追加する。
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJh...
SUPABASE_SERVICE_ROLE_KEY=eyJh...
型定義ファイルをSupabase CLIで自動生成すると、DBのスキーマに合った型安全なクエリが書ける(任意だが推奨)。
# Supabase CLIで型定義を自動生成(任意)
npx supabase gen types typescript --project-id xxxx > types/supabase.ts
💡 ポイント:
@supabase/supabase-jsと@supabase/ssrは両方インストールが必要。@supabase/ssrは内部で@supabase/supabase-jsに依存している。
DB・Auth・Storage・APIをGUIで操作できる管理画面
ダッシュボード
├── Table Editor : テーブルのCRUD操作(Notion風UI)
├── SQL Editor : 生SQLを直接実行
├── Authentication : ユーザー管理・認証プロバイダ設定
├── Storage : バケット作成・ファイル管理
└── Project Settings
├── API : URL・APIキーの確認
└── Database : 接続文字列(Prisma/Drizzle用)
Next.js開発でよく使う場所:
| 作業 | 使う場所 |
|---|---|
| テーブル設計 | Table Editor / SQL Editor |
| ORM接続文字列 | Project Settings > Database |
| APIキー確認 | Project Settings > API |
| ユーザー確認 | Authentication > Users |
💡 ポイント: 現在のUIにはAPI Docsページは存在しない。APIリファレンスは公式ドキュメントを参照する。
Settings > Databaseの接続文字列はPrismaやDrizzleのDATABASE_URLとして使う。Vercelとの相性はTransaction poolerのURLが良い。
テーブル作成後、RLSポリシーを必ず設定する。ポリシーなし=全ブロック
RLSとは: PostgreSQL標準の機能で「どのユーザーがどの行を読み書きできるか」をDB側で制御する仕組み。アプリ側ではなくDB側で制御するため、バックエンドのコードなしで安全なアクセス制御を実現できる。Djangoのpermission_classesをデフォルトで全テーブルにかけるイメージ。
CREATE TABLE posts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id),
title TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLSを有効化
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- ポリシー:自分の投稿のみ参照可
CREATE POLICY "自分の投稿のみ参照"
ON posts FOR SELECT
USING (auth.uid() = user_id);
-- ポリシー:自分の投稿のみ作成可
CREATE POLICY "自分の投稿のみ作成"
ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id);
💡 ポイント:
auth.uid()はSupabase固有の拡張関数。JWTからログイン中ユーザーのIDを取得する(Djangoのrequest.userに相当)。RLS自体はPostgreSQL標準の機能。
RLSの状態まとめ
RLS ON + ポリシーなし → 全ブロック(0件返る)← 初心者がハマる
RLS ON + ポリシーあり → ポリシーに従ってアクセス制御
RLS OFF → 全ユーザーが全データアクセス可(危険)
用語
| 用語 | 説明 |
|---|---|
| RLS | Row Level Security。行レベルのアクセス制御。PostgreSQL標準機能 |
| USING | SELECT・UPDATE・DELETE時の条件(読み取り) |
| WITH CHECK | INSERT・UPDATE時の条件(書き込み) |
| auth.uid() | Supabase拡張関数。JWTから現在のユーザーIDを取得 |
| auth.users | Supabaseが管理する認証テーブル |
参考
JWTベースの認証をダッシュボード設定だけで有効化できる
Authentication
├── Providers : 認証方法の有効化
│ ├── Email : メール/パスワード(デフォルトON)
│ ├── Google
│ ├── GitHub など
└── URL Configuration
├── Site URL : 本番URLを設定
└── Redirect URLs : 許可するリダイレクト先
// lib/supabase/client.ts(Clientコンポーネント用)
import { createBrowserClient } from '@supabase/ssr'
export const createClient = () =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// サインアップ
const { data, error } = await supabase.auth.signUp({
email: 'user@example.com',
password: 'password',
})
// ログイン
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'password',
})
// ログアウト
const { error } = await supabase.auth.signOut()
💡 ポイント: Next.js App RouterではSSR対応のため
@supabase/ssrパッケージを使う。@supabase/supabase-jsはlocalStorage依存でブラウザのみ動作。@supabase/ssrはCookieベースのためサーバー/クライアント両方でセッションを共有できる。
用語
| 用語 | 説明 |
|---|---|
| @supabase/ssr | SSR対応のSupabaseクライアント。Cookieでセッション管理 |
| @supabase/supabase-js | ブラウザ専用。localStorage依存のためSSRでは使えない |
| JWT | JSON Web Token。認証情報をトークンとして管理する仕組み |
S3互換のファイルストレージ。バケット単位でPublic/Privateを制御する
Storage
└── New Bucket
├── Name : バケット名(例: avatars)
├── Public : ON → 誰でもURL直アクセス可
│ OFF → 署名付きURLが必要
└── File size limit / Allowed MIME types(任意)
// アップロード
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${userId}/avatar.png`, file, { upsert: true })
// 公開URLの取得(Publicバケットの場合)
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(`${userId}/avatar.png`)
// 署名付きURL(Privateバケットの場合)
const { data } = await supabase.storage
.from('avatars')
.createSignedUrl(`${userId}/avatar.png`, 60) // 60秒有効
💡 ポイント: Privateバケットでも署名付きURLで一時的なアクセスを許可できる(AWSのS3 Presigned URLと同じ仕組み)。有効期限を短く設定することでURLが漏れてもリスクを最小化できる。
バケットの使い分け
| ユースケース | バケット種別 | 取得方法 |
|---|---|---|
| プロフィール画像(誰でも見える) | Public | getPublicUrl() |
| 契約書PDF(本人のみ) | Private | createSignedUrl() |
用語
| 用語 | 説明 |
|---|---|
| バケット | ファイルを格納するコンテナ。S3のバケットと同概念 |
| 署名付きURL | 有効期限付きの一時アクセスURL。Privateファイルの共有に使用 |
Server/Clientで異なるSupabaseクライアントを使い分ける
lib/supabase/
├── client.ts # Clientコンポーネント用
└── server.ts # Serverコンポーネント・Route Handler・Middleware用
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export const createClient = () =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export const createClient = async () => {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
}
使い分けまとめ
| 場所 | 使うクライアント |
|---|---|
| Clientコンポーネント | createBrowserClient |
| Serverコンポーネント | createServerClient |
| Route Handler | createServerClient |
| Middleware | createServerClient |
💡 ポイント: Middlewareでのセッション更新を忘れると、セッションが期限切れのまま更新されないバグが発生する。必ずMiddlewareでも
createServerClientを使うこと。
createServerClientを使ってセッション・ユーザー情報をサーバー側で取得する
ServerコンポーネントではcreateClient(server.ts)を使い、getUser()でログイン中のユーザーを取得する。getSession()はキャッシュされた値を返す場合があるため、セキュリティ上重要な処理ではgetUser()を使うこと。
// app/dashboard/page.tsx(Serverコンポーネント)
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const supabase = await createClient()
// ✅ 推奨: getUser()はJWTを毎回検証する
const { data: { user }, error } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
// DBクエリ(RLSポリシーがauth.uid()に基づいて自動フィルタリング)
const { data: posts } = await supabase
.from('posts')
.select('*')
return <div>{/* ... */}</div>
}
// ❌ 非推奨: getSession()はキャッシュされた値を返す場合がある
const { data: { session } } = await supabase.auth.getSession()
💡 ポイント:
getUser()はSupabaseサーバーにリクエストを送ってJWTを検証するため、改ざんされたJWTを検知できる。getSession()はCookieの値をそのまま返すだけなのでセキュリティリスクがある。
Middlewareでセッションを更新しないと、期限切れのセッションがサーバー側で取得できなくなる
MiddlewareはすべてのリクエストでCookieを読み書きしてセッションを更新する役割を担う。これを省略すると、ServerコンポーネントでgetUser()を呼んでも期限切れのセッションが返り続ける。
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// ⚠️ getUser()を必ず呼ぶこと。これがセッション更新をトリガーする
const { data: { user } } = await supabase.auth.getUser()
// 未認証ユーザーを/loginにリダイレクト(保護ルートの例)
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
💡 ポイント:
supabaseResponseを最後に必ず返すこと。NextResponse.next()を新しく作って返すとCookieのセット処理が失われ、セッションが更新されない。
Supabase CLIでローカルにDB環境を立ち上げ、マイグレーションで本番と同期管理する
チーム開発やCI/CDでは、ローカルにSupabase環境を立てることで本番DBに依存しない開発フローを実現できる。内部的にDockerを使用する。
# Supabase CLIのインストール
npm install -g supabase
# ログイン
supabase login
# プロジェクトの初期化(初回のみ)
supabase init
# ローカル環境の起動(Docker必須)
supabase start
# 停止
supabase stop
起動後、http://localhost:54323でローカルのダッシュボードにアクセスできる。
マイグレーション管理
# マイグレーションファイルの作成
supabase migration new create_posts_table
# ローカルDBにマイグレーションを適用
supabase db reset
# 本番DBにマイグレーションを適用
supabase db push
# 型定義ファイルの生成(ローカルから)
supabase gen types typescript --local > types/supabase.ts
💡 ポイント:
supabase db pushで本番に反映する前にsupabase db resetでローカル検証を行う習慣をつけること。マイグレーションファイルはGit管理することでチーム間のスキーマ差異を防げる。
用語
| 用語 | 説明 |
|---|---|
| マイグレーション | DBスキーマの変更履歴をファイルで管理する仕組み |
| supabase start | ローカルにPostgreSQL+Auth+Storageをまとめて起動 |
| supabase db push | ローカルのマイグレーションを本番DBに適用 |
参考
キーの扱いとRLSの設定漏れの2パターンに集約される
| ミス | 原因 | 改善策 |
|---|---|---|
SERVICE_ROLE_KEYにNEXT_PUBLIC_をつける | クライアントに漏洩 | サーバーサイドのみで使用 |
| RLS有効化だけしてポリシーを書かない | 全アクセスブロック(0件返る) | ポリシーを必ず設定する |
| RLSを無効化したままにする | 全ユーザーが全データ参照可能 | 必ずRLSをONにする |
@supabase/ssrではなくsupabase-jsを使う | サーバーでセッション取得不可 | App Routerでは@supabase/ssrを使う |
| Middlewareでセッション更新しない | セッション期限切れが更新されない | MiddlewareでcreateServerClientを使う |
| Privateバケットに署名なしでアクセス | アクセス不可 | createSignedUrl()を使う |
getSession()をセキュリティ処理に使う | キャッシュ値を返すためリスクあり | 重要処理はgetUser()を使う |
リリース前にセキュリティと設定の抜け漏れを確認する
セキュリティ
SERVICE_ROLE_KEYにNEXT_PUBLIC_がついていないかSERVICE_ROLE_KEYをクライアントコンポーネントで使っていないかENABLE ROW LEVEL SECURITY)認証・セッション
middleware.tsが存在し、supabase.auth.getUser()を呼んでいるかgetUser()(getSession()ではなく)を使っているか環境変数
.env.localが.gitignoreに含まれているか