database

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

2025.12.02

Supabaseとは

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

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

セットアップ

# 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*'],
};

参考リンク

← 一覧に戻る