Supabase実践ガイド - オープンソースFirebase代替でフルスタック開発

中級 | 50分 で読める | 2025.12.02

公式ドキュメント

今回やること

この記事では、Supabaseで認証、データベース、RLS、Edge Functionsを試す時の全体像を確認します。

Supabaseは便利ですが、RLSを理解しないまま公開するとデータ露出につながります。まずローカルまたは学習用プロジェクトで安全に試してください。

Supabaseとは

Supabaseは、オープンソースのFirebase代替として注目されているBaaS(Backend as a Service)プラットフォームです。PostgreSQLをベースに、認証、リアルタイム、ストレージなどの機能を提供します。

ポイント: SupabaseはPostgRESTによりテーブル定義から自動でREST APIを生成します。バックエンドコードを書かずにCRUD操作が可能です。

flowchart TB
 subgraph Client["Client Application"]
 Apps["React, Next.js, Vue, Flutter, etc."]
 end

 subgraph Supabase["Supabase Platform"]
 subgraph Services["Services Layer"]
 Auth["Auth<br/>(GoTrue)"]
 Realtime["Realtime<br/>(Elixir)"]
 Storage["Storage<br/>(S3互換)"]
 end
 subgraph API["API Layer"]
 Edge["Edge Functions<br/>(Deno)"]
 PostgREST["PostgREST<br/>(Auto-generated API)"]
 end
 subgraph DB["Database Layer"]
 PostgreSQL["PostgreSQL<br/>(Row Level Security, Extensions)"]
 end
 end

 Client --> Services
 Client --> API
 Services --> DB
 API --> DB

注意: Row Level Security(RLS)を有効にしないと、すべてのユーザーがすべてのデータにアクセスできてしまいます。テーブル作成後は必ずRLSを設定してください。

実践メモ: Supabase CLIを使えばローカルでPostgreSQLを起動し、本番と同じ環境で開発・テストできます。マイグレーションの管理も容易になります。

セットアップ

# Supabase CLIのインストール
npm install -g supabase

# ローカル開発環境の起動
supabase init
supabase start

# クライアントライブラリのインストール
npm install @supabase/supabase-js

クライアント初期化

// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);

// サーバーサイド用(Service Role Key)
export const supabaseAdmin = createClient<Database>(
 supabaseUrl,
 process.env.SUPABASE_SERVICE_ROLE_KEY!,
 {
 auth: {
 autoRefreshToken: false,
 persistSession: false,
 },
 }
);

型生成

# データベーススキーマから型を生成
supabase gen types typescript --local > lib/database.types.ts
// lib/database.types.ts (自動生成)
export type Database = {
 public: {
 Tables: {
 users: {
 Row: {
 id: string;
 email: string;
 name: string;
 avatar_url: string | null;
 created_at: string;
 };
 Insert: {
 id?: string;
 email: string;
 name: string;
 avatar_url?: string | null;
 created_at?: string;
 };
 Update: {
 id?: string;
 email?: string;
 name?: string;
 avatar_url?: string | null;
 created_at?: string;
 };
 };
 posts: {
 Row: {
 id: number;
 title: string;
 content: string;
 author_id: string;
 published: boolean;
 created_at: string;
 };
 Insert: Omit<Posts['Row'], 'id' | 'created_at'>;
 Update: Partial<Posts['Insert']>;
 };
 };
 };
};

認証

メール/パスワード認証

// サインアップ
async function signUp(email: string, password: string, name: string) {
 const { data, error } = await supabase.auth.signUp({
 email,
 password,
 options: {
 data: {
 name,
 },
 },
 });

 if (error) throw error;
 return data;
}

// ログイン
async function signIn(email: string, password: string) {
 const { data, error } = await supabase.auth.signInWithPassword({
 email,
 password,
 });

 if (error) throw error;
 return data;
}

// ログアウト
async function signOut() {
 const { error } = await supabase.auth.signOut();
 if (error) throw error;
}

// セッション取得
async function getSession() {
 const { data: { session } } = await supabase.auth.getSession();
 return session;
}

OAuth認証

// Google認証
async function signInWithGoogle() {
 const { data, error } = await supabase.auth.signInWithOAuth({
 provider: 'google',
 options: {
 redirectTo: `${window.location.origin}/auth/callback`,
 queryParams: {
 access_type: 'offline',
 prompt: 'consent',
 },
 },
 });

 if (error) throw error;
 return data;
}

// GitHub認証
async function signInWithGitHub() {
 const { data, error } = await supabase.auth.signInWithOAuth({
 provider: 'github',
 options: {
 redirectTo: `${window.location.origin}/auth/callback`,
 scopes: 'read:user user:email',
 },
 });

 if (error) throw error;
 return data;
}

認証状態の監視

// hooks/useAuth.ts
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
import type { User, Session } from '@supabase/supabase-js';

export function useAuth() {
 const [user, setUser] = useState<User | null>(null);
 const [session, setSession] = useState<Session | null>(null);
 const [loading, setLoading] = useState(true);

 useEffect(() => {
 // 初期セッション取得
 supabase.auth.getSession().then(({ data: { session } }) => {
 setSession(session);
 setUser(session?.user ?? null);
 setLoading(false);
 });

 // 認証状態の変更を監視
 const { data: { subscription } } = supabase.auth.onAuthStateChange(
 (_event, session) => {
 setSession(session);
 setUser(session?.user ?? null);
 }
 );

 return () => subscription.unsubscribe();
 }, []);

 return { user, session, loading };
}

データベース操作

CRUD操作

// 取得
async function getPosts() {
 const { data, error } = await supabase
 .from('posts')
 .select('*, author:users(name, avatar_url)')
 .eq('published', true)
 .order('created_at', { ascending: false })
 .limit(10);

 if (error) throw error;
 return data;
}

// 単一レコード取得
async function getPost(id: number) {
 const { data, error } = await supabase
 .from('posts')
 .select('*, author:users(name, avatar_url), comments(*, author:users(name))')
 .eq('id', id)
 .single();

 if (error) throw error;
 return data;
}

// 作成
async function createPost(post: { title: string; content: string }) {
 const { data: { user } } = await supabase.auth.getUser();

 const { data, error } = await supabase
 .from('posts')
 .insert({
 title: post.title,
 content: post.content,
 author_id: user!.id,
 })
 .select()
 .single();

 if (error) throw error;
 return data;
}

// 更新
async function updatePost(id: number, updates: Partial<Post>) {
 const { data, error } = await supabase
 .from('posts')
 .update(updates)
 .eq('id', id)
 .select()
 .single();

 if (error) throw error;
 return data;
}

// 削除
async function deletePost(id: number) {
 const { error } = await supabase
 .from('posts')
 .delete()
 .eq('id', id);

 if (error) throw error;
}

フィルタリング

// 複合条件
const { data } = await supabase
 .from('products')
 .select('*')
 .gte('price', 100)
 .lte('price', 500)
 .in('category', ['electronics', 'books'])
 .ilike('name', '%phone%')
 .not('deleted_at', 'is', null);

// OR条件
const { data } = await supabase
 .from('posts')
 .select('*')
 .or('status.eq.published,author_id.eq.123');

// 全文検索
const { data } = await supabase
 .from('posts')
 .select('*')
 .textSearch('title', 'TypeScript React');

// 範囲クエリ
const { data } = await supabase
 .from('events')
 .select('*')
 .gte('start_date', '2024-01-01')
 .lte('end_date', '2024-12-31');

Row Level Security (RLS)

-- テーブルにRLSを有効化
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- ポリシー: 公開記事は誰でも閲覧可能
CREATE POLICY "Public posts are viewable by everyone"
 ON posts FOR SELECT
 USING (published = true);

-- ポリシー: 自分の記事は全て閲覧可能
CREATE POLICY "Users can view own posts"
 ON posts FOR SELECT
 USING (auth.uid() = author_id);

-- ポリシー: 認証ユーザーは記事を作成可能
CREATE POLICY "Authenticated users can create posts"
 ON posts FOR INSERT
 WITH CHECK (auth.uid() = author_id);

-- ポリシー: 自分の記事のみ更新可能
CREATE POLICY "Users can update own posts"
 ON posts FOR UPDATE
 USING (auth.uid() = author_id);

-- ポリシー: 自分の記事のみ削除可能
CREATE POLICY "Users can delete own posts"
 ON posts FOR DELETE
 USING (auth.uid() = author_id);
flowchart TB
 Request["リクエスト<br/>SELECT * FROM posts WHERE id = 1"]
 RLS["RLS Policy Check<br/>published = true OR auth.uid() = author_id"]
 Match["ポリシー適合<br/>→ データ返却"]
 NoMatch["ポリシー不適合<br/>→ 空の結果"]

 Request --> RLS
 RLS --> Match
 RLS --> NoMatch

リアルタイム

リアルタイムサブスクリプション

// テーブルの変更を監視
const subscription = supabase
 .channel('posts-changes')
 .on(
 'postgres_changes',
 {
 event: '*', // INSERT, UPDATE, DELETE, または *
 schema: 'public',
 table: 'posts',
 },
 (payload) => {
 console.log('Change received:', payload);

 if (payload.eventType === 'INSERT') {
 console.log('New post:', payload.new);
 } else if (payload.eventType === 'UPDATE') {
 console.log('Updated:', payload.old, '->', payload.new);
 } else if (payload.eventType === 'DELETE') {
 console.log('Deleted:', payload.old);
 }
 }
 )
 .subscribe();

// フィルター付きサブスクリプション
const subscription = supabase
 .channel('user-posts')
 .on(
 'postgres_changes',
 {
 event: 'INSERT',
 schema: 'public',
 table: 'posts',
 filter: `author_id=eq.${userId}`,
 },
 (payload) => {
 console.log('New post by user:', payload.new);
 }
 )
 .subscribe();

// クリーンアップ
subscription.unsubscribe();

プレゼンス(オンライン状態)

// チャットルームのプレゼンス
const room = supabase.channel('chat-room');

room
 .on('presence', { event: 'sync' }, () => {
 const state = room.presenceState();
 console.log('Online users:', Object.keys(state));
 })
 .on('presence', { event: 'join' }, ({ key, newPresences }) => {
 console.log('User joined:', key);
 })
 .on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
 console.log('User left:', key);
 })
 .subscribe(async (status) => {
 if (status === 'SUBSCRIBED') {
 await room.track({
 user_id: userId,
 online_at: new Date().toISOString(),
 });
 }
 });

ストレージ

// ファイルアップロード
async function uploadFile(file: File, path: string) {
 const { data, error } = await supabase.storage
 .from('avatars')
 .upload(path, file, {
 cacheControl: '3600',
 upsert: true,
 });

 if (error) throw error;

 // 公開URLを取得
 const { data: { publicUrl } } = supabase.storage
 .from('avatars')
 .getPublicUrl(path);

 return publicUrl;
}

// ファイルダウンロード
async function downloadFile(path: string) {
 const { data, error } = await supabase.storage
 .from('documents')
 .download(path);

 if (error) throw error;
 return data;
}

// 署名付きURL(有効期限付き)
async function getSignedUrl(path: string) {
 const { data, error } = await supabase.storage
 .from('private-files')
 .createSignedUrl(path, 3600); // 1時間有効

 if (error) throw error;
 return data.signedUrl;
}

// ファイル削除
async function deleteFile(path: string) {
 const { error } = await supabase.storage
 .from('avatars')
 .remove([path]);

 if (error) throw error;
}

// フォルダ内のファイル一覧
async function listFiles(folder: string) {
 const { data, error } = await supabase.storage
 .from('documents')
 .list(folder, {
 limit: 100,
 sortBy: { column: 'created_at', order: 'desc' },
 });

 if (error) throw error;
 return data;
}

Edge Functions

// supabase/functions/hello/index.ts
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

serve(async (req) => {
 try {
 // Supabaseクライアント作成
 const supabaseClient = createClient(
 Deno.env.get('SUPABASE_URL') ?? '',
 Deno.env.get('SUPABASE_ANON_KEY') ?? '',
 {
 global: {
 headers: { Authorization: req.headers.get('Authorization')! },
 },
 }
 );

 // 認証ユーザー取得
 const { data: { user } } = await supabaseClient.auth.getUser();

 if (!user) {
 return new Response(
 JSON.stringify({ error: 'Unauthorized' }),
 { status: 401, headers: { 'Content-Type': 'application/json' } }
 );
 }

 const { name } = await req.json();

 return new Response(
 JSON.stringify({ message: `Hello ${name}!`, userId: user.id }),
 { headers: { 'Content-Type': 'application/json' } }
 );
 } catch (error) {
 return new Response(
 JSON.stringify({ error: error.message }),
 { status: 500, headers: { 'Content-Type': 'application/json' } }
 );
 }
});
// クライアントからの呼び出し
const { data, error } = await supabase.functions.invoke('hello', {
 body: { name: 'World' },
});

Next.js App Router統合

// app/auth/callback/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
 const requestUrl = new URL(request.url);
 const code = requestUrl.searchParams.get('code');

 if (code) {
 const supabase = createRouteHandlerClient({ cookies });
 await supabase.auth.exchangeCodeForSession(code);
 }

 return NextResponse.redirect(new URL('/', requestUrl.origin));
}
// middleware.ts
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function middleware(req: NextRequest) {
 const res = NextResponse.next();
 const supabase = createMiddlewareClient({ req, res });

 const { data: { session } } = await supabase.auth.getSession();

 // 保護されたルートへのアクセスチェック
 if (req.nextUrl.pathname.startsWith('/dashboard') && !session) {
 return NextResponse.redirect(new URL('/login', req.url));
 }

 return res;
}

export const config = {
 matcher: ['/dashboard/:path*'],
};

動作確認

Supabaseで最初に確認する項目は次の通りです。

確認項目見る場所OKの状態
環境変数.env.localURLとanon keyが設定されている
認証Authログサインアップとログインが成功する
RLSTable Editor / SQL Editor未ログイン時に保護データが読めない
API呼び出しBrowser DevTools Network200系または意図した401/403が返る

本番投入前は、anon keyだけで取得できるデータがないかを必ず確認します。

練習

次の小さなアプリを作ると、Supabaseの重要部分を一通り確認できます。

  1. profiles テーブルを作る
  2. サインアップしたユーザーだけが自分のプロフィールを読めるRLSを設定する
  3. ログイン後にプロフィールを表示する
  4. 自分のプロフィールだけ更新できるようにする
  5. DevToolsのNetworkで401/403/200の違いを確認する

次のステップ

RLSを理解する前にSQLの基礎が不安な場合は SELECT / WHERE / ORDER BY / LIMITを練習する から確認してください。フルスタック構成の比較には Firebase Authentication実践ガイドPostgreSQL入門 も参考になります。

まとめ

この記事では、手順を追って動かしながら重要な確認ポイントを押さえることが大切です。まずは学習用の最小構成で動作確認し、本番利用する前にセキュリティ、権限、エラー処理、運用手順を見直してください。

参考リンク

参考リソース

← 一覧に戻る
PR
PR
PR
PR