Supabaseに入門しました

概要

オープンソースでFirebaseの代替として人気のあるsupabaseをちょっと試してみました(チュートリアル+α)。

機能として以下があります。

  1. データベース (Postgres)
  2. 認証
  3. ストレージ
  4. エッジファクション

個人開発にはとても良さそうです。ただ無料プランだと1週間使わないと停止するそうです(どういうことかよくわかっていない。料金プラン参照)。

認証(パスワードレスのマジックリンク)付きでデータベース、プロフィール画像はストレージに保存する次のようなtodoアプリを作成しました。

作成したto-doアプリ

開発環境の構築には、ViteReactTypeScriptを採用しました。

サインイン及びプロジェクトの開始

[Sign In with GitHub]ボタンから簡単におこなえます。

データベーススキーマの設定

2種類の方法にて、todosテーブルとprofilesテーブルを生成します。まずは、todosテーブルを作成します。

  1. supabaseのダッシュボードのアイコンの並びからSQL Editorセクションに移動する。
  2. Quick startTodo Listというスターターがあるのでクリックする。
  3. [Run]ボタンをクリックする。

この画面には次のデータベーススキーマ(データベースの構造)を設定する次のようなSQLが表示されています。

create table todos (
  id bigint generated by default as identity primary key,
  user_id uuid references auth.users not null,
  task text check (char_length(task) > 3),
  is_complete boolean default false,
  inserted_at timestamp with time zone default timezone('utc'::text, now()) not null
);
alter table todos enable row level security;
create policy "Individuals can create todos." on todos for
    insert with check (auth.uid() = user_id);
create policy "Individuals can view their own todos. " on todos for
    select using (auth.uid() = user_id);
create policy "Individuals can update their own todos." on todos for
    update using (auth.uid() = user_id);
create policy "Individuals can delete their own todos." on todos for
    delete using (auth.uid() = user_id);
  • ここで前半の create table のブロックでは新しいテーブルtodosを定義しています。
  • alter table todos enable row level security は、テーブル定義をRLS(Row Level Security、行単位のセキュリティ)を有効に変更しています。
    • RLSPostgresのポリシーを書くことによってテーブルへのアクセスを制限します。
    • RLSが有効でない場合は、anonキーを持つ誰もがあなたのデータを変更及び削除することができます。
  • create policy は、テーブルに新しい行単位のセキュリティポリシーを定義します。

続いてprofilesテーブルを生成します。

  1. supabaseのダッシュボードのアイコンの並びからSQL Editorセクションに移動する。
  2. [+ New query]ボタンをクリックする。
  3. 右の編集画面に下記SQLを貼り付けて[Run]ボタンをクリックする。
  4. サイドバーに示されているNew Queryは不要なので右クリックメニューの[Remove query]で削除する。
-- Create a table for public "profiles"
create table profiles (
  id uuid references auth.users not null,
  updated_at timestamp with time zone,
  username text unique,
  avatar_url text,
  website text,

  primary key (id),
  unique(username),
  constraint username_length check (char_length(username) >= 3)
);

alter table profiles enable row level security;

create policy "Public profiles are viewable by everyone."
  on profiles for select
  using ( true );

create policy "Users can insert their own profile."
  on profiles for insert
  with check ( auth.uid() = id );

create policy "Users can update own profile."
  on profiles for update
  using ( auth.uid() = id );

-- Set up Realtime!
begin;
  drop publication if exists supabase_realtime;
  create publication supabase_realtime;
commit;
alter publication supabase_realtime add table profiles;

-- Set up Storage!
insert into storage.buckets (id, name)
values ('avatars', 'avatars');

create policy "Avatar images are publicly accessible."
  on storage.objects for select
  using ( bucket_id = 'avatars' );

create policy "Anyone can upload an avatar."
  on storage.objects for insert
  with check ( bucket_id = 'avatars' );

これで2つのテーブルが作成されていることが、supabaseのダッシュボードのアイコンの並びからDatabaseをクリックすると確認できます。

Database

APIキーの取得

  1. supabaseのダッシュボードのアイコンの並びからSettingsセクションに移動する。
  2. サイドバーのAPIをクッリクする。
  3. 以下などが確認できます。
    • Project URL: データベースのクエリ及び管理のためのRESTfulエンドポイント
    • Project API keys: APIAPIゲートウェイで保護されていて各リクエストでAPIキーが必要。これらのキーはSupabace クライアントライブラリで使用可能。
      • anon:ブラウザで安全に使用可能なキー
      • service_role: 秘密にしておく必要があるキー

Reactアプリの構築

今回はVite (react-ts)を使います。

$ yarn create vite pitang-todo --template react-ts
$ cd pitang-todo
$ yarn add @supabase/supabase-js
$ yarn add --dev @types/node   // process.envに対してのTypeScriptエラーなどを消す
$ code .

環境変数

ルートフォルダに、.envファイルを作成し、手前の手順のProject URLanonの値を設定します。

VITE_SUPABASE_URL=YOUR_SUPABASE_URL
VITE_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

Supabaseクライアント初期化ファイル

Supabaseクライアントを初期化するためのヘルパーファイルを作成します。これらの環境変数はブラウザに晒されても大丈夫とのことです。

// src\utils\supabaseClient.ts

import { createClient } from '@supabase/supabase-js';

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);

Notistackの使用

今回はalertの変わりにNotistackという通知ライブラリを使用しています。次でインストールします。

$ yarn add notistack @mui/material @emotion/react @emotion/styled

使い方の概要はAppコンポーネントの中身を<SnackbarProvider>で囲って、各コンポーネントでは次のようにしてalertを置き換えてメッセージを出します。

:
import { useSnackbar } from 'notistack';
:
  const { enqueueSnackbar } = useSnackbar();
:
  enqueueSnackbar('リンクのためのメールをご確認ください。', {
    variant: 'info',
  });

Accountコンポーネント

ユーザーがログインしたあと、プロフィールの詳細を編集し、アカウントを管理できるようにAccountコンポーネントを追加します。

このコードは、公式ドキュメントのQuickstart: ReactBonus: Profile photosも組み込んだもの)をベースにTypeScript化し若干変更したものに対し、Todos コンポーネントを埋め込んだものになります。

// src\components\Account.tsx

import { useState, useEffect } from 'react';
import { supabase } from '../utils/supabaseClient';
import { Session } from '@supabase/gotrue-js';
import { useSnackbar } from 'notistack';
import type { SnackbarMessage } from 'notistack';
import Avatar from './Avatar';
import Todos from './Todos';

type Props = {
  session: Session;
};

export default function Account({ session }: Props) {
  const [loading, setLoading] = useState(true);
  const [username, setUsername] = useState<any>(null);
  const [website, setWebsite] = useState<any>(null);
  const [avatar_url, setAvatarUrl] = useState<any>(null);
  const { enqueueSnackbar } = useSnackbar();

  useEffect(() => { // ★ ログインしたら getProfile()を呼ぶ
    getProfile();
  }, [session]);

  async function getProfile() {
    try {
      setLoading(true);
      const user = supabase.auth.user(); // ★ ログインしているユーザーを取得

      const { data, error, status } = await supabase // ★ 当該ユーザーの profiles を取得
        .from('profiles')
        .select(`username, website, avatar_url`)
        .eq('id', user?.id)
        .single();

      if (error && status !== 406) {
        throw error;
      }

      if (data) { // ★ ユーザー名、Webサイト、アバタURLを設定
        setUsername(data.username);
        setWebsite(data.website);
        setAvatarUrl(data.avatar_url);
      }
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    } finally {
      setLoading(false);
    }
  }

  async function updateProfile({ // ★ 当該ユーザーの profiles を更新
    username,
    website,
    avatar_url,
  }: {
    username: any;
    website: any;
    avatar_url: any;
  }) {
    try {
      setLoading(true);
      const user = supabase.auth.user();

      const updates = {
        id: user?.id,
        username,
        website,
        avatar_url,
        updated_at: new Date(),
      };

      let { error } = await supabase.from('profiles').upsert(updates, {
        returning: 'minimal', // Don't return the value after inserting
      });

      if (error) {
        throw error;
      }
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className='form-widget'>
      <h1>{session?.user?.email}</h1>
      <Avatar // ★ アバター
        url={avatar_url}
        size={150}
        onUpload={(url: any) => {
          setAvatarUrl(url);
          updateProfile({ username, website, avatar_url: url });
        }}
      />

      <div className='flex'>
        <label htmlFor='username' className='w-28'> // ★ 名前
          名前
        </label>
        <input
          id='username'
          type='text'
          value={username || ''}
          onChange={(e) => setUsername(e.target.value)}
        />
      </div>
      <div className='flex'>
        <label htmlFor='website' className='w-28'> // ★ ウェブサイト
          ウェブサイト
        </label>
        <input
          id='website'
          type='website'
          value={website || ''}
          onChange={(e) => setWebsite(e.target.value)}
        />
      </div>

      <div className='flex'>
        <button // ★ [更新]ボタン
          className='button primary'
          onClick={() => updateProfile({ username, website, avatar_url })}
          disabled={loading}
        >
          {loading ? '読み込み中...' : '更新'}
        </button>
        <button className='button' onClick={() => supabase.auth.signOut()}> // ★ [ログアウト]ボタン
          ログアウト
        </button>
      </div>
      <Todos session={session} />
    </div>
  );
}

Avatarコンポーネント

Supabaseプロジェクトには、写真や動画といった大容量ファイルを管理するためのStorageという機能があります。チュートリアルにそって、プロフィール写真を管理・表示できるようにします。ここでAvatarコンポーネントを作りますが、以下のバツを付けたところを非表示にするために、@reach/visually-hiddenを使用します。

<input type=”file”>を非表示にしたい
$ yarn add @reach/visually-hidden
// src\components\Avatar.tsx

import { useEffect, useState } from 'react';
import { supabase } from '../utils/supabaseClient';
import VisuallyHidden from '@reach/visually-hidden';
import { useSnackbar } from 'notistack';
import type { SnackbarMessage } from 'notistack';

export default function Avatar({
  url,
  size,
  onUpload, // ★ 親のAccountの関数
}: {
  url: any;
  size: any;
  onUpload: any;
}) {
  const [avatarUrl, setAvatarUrl] = useState<any>(null);
  const [uploading, setUploading] = useState(false);
  const { enqueueSnackbar } = useSnackbar();

  useEffect(() => {
    if (url) downloadImage(url); // ★ 画像URLが変わったら実行
  }, [url]);

  const downloadImage = async (path: any) => {
    try {
      const { data, error } = await supabase.storage // ★画像をsupabaseからダウンロード
        .from('avatars')
        .download(path);
      if (error) {
        throw error;
      }
      if (typeof data === 'object') { // ★引数で指定されたオブジェクトを表すURLを含むDOMStringを生成
        const url = URL.createObjectURL(data as Blob);  
        setAvatarUrl(url);
      }
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    }
  };

  const uploadAvatar = async (event: any) => { // ★ ファイル選択後の処理
    try {
      setUploading(true);

      if (!event.target.files || event.target.files.length === 0) {
        throw new Error('アップロードする画像を選択してください。');
      }

      const file = event.target.files[0];
      const fileExt = file.name.split('.').pop();
      const fileName = `${Math.random()}.${fileExt}`;
      const filePath = `${fileName}`;

      let { error: uploadError } = await supabase.storage // ★ ファイルをsupabaseにアップロード
        .from('avatars')
        .upload(filePath, file);

      if (uploadError) {
        throw uploadError;
      }

      onUpload(filePath); // ★ 親のAccountのほうで画像URLを更新する
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    } finally {
      setUploading(false);
    }
  };

  return (
    <div style={{ width: size }} aria-live='polite'> // ★ 要素が更新されることを適切なタイミングで示す
      <img
        src={avatarUrl ? avatarUrl : `https://place-hold.it/${size}x${size}`}
        alt={avatarUrl ? 'プロフィール画像' : '画像なし'}
        className='avatar image'
        style={{ height: size, width: size }}
      />
      {uploading ? (
        'アップロード中...'
      ) : (
        <>
          <label className='button primary block' htmlFor='single'> // ★ ファイル選択ダイアログを表示
            画像アップロード
          </label>
          <VisuallyHidden> // ★ 次を非表示にする
            <input // ★ ファイル選択ダイアログ
              type='file'
              id='single'
              accept='image/*'
              onChange={uploadAvatar}
              disabled={uploading}
            />
          </VisuallyHidden>
        </>
      )}
    </div>
  );
}

Todosコンポーネント

// src\components\Todos.tsx

import { useState, useEffect } from 'react';
import { supabase } from '../utils/supabaseClient';
import { Session } from '@supabase/gotrue-js';
import { useSnackbar } from 'notistack';
import type { SnackbarMessage } from 'notistack';
import TodoCard from './TodoCard';
import type { Todo } from './TodoCard';

type Props = {
  session: Session;
};

export default function Todos({ session }: Props) {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [newTask, setNewTask] = useState('');
  const { enqueueSnackbar } = useSnackbar();

  useEffect(() => {
    getTodos(); // ★ 初期化
  }, [session]);

  async function deleteTask(id: number) { // ★ To-doタスク削除処理
    if (!session) {
      return;
    }
    try {
      const newTodos: Todo[] = todos.filter((todo) => todo.id !== id);

      const { data, error } = await supabase
        .from<Todo>('todos')
        .delete()
        .match({ id: id });

      if (!data && error) {
        throw error;
      }
      setTodos(newTodos);
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    } finally {
      setLoading(false);
    }
  }

  async function toggleTaskComplete(id: number) { // ★ To-doタスクの完了状態をトグル
    if (!session) {
      return;
    }
    try {
      let newTodo: Todo | undefined;
      const newTodos: Todo[] = todos.map((todo) => {
        if (todo.id !== id) {
          return todo;
        } else {
          newTodo = todo;
          todo.is_complete = !todo.is_complete;
          return newTodo;
        }
      });
      if (newTodo) {
        const { data, error } = await supabase
          .from<Todo>('todos')
          .update({ is_complete: newTodo.is_complete })
          .match({ id: id });

        if (!data && error) {
          throw error;
        }
        setTodos(newTodos);
      }
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    }
  }

  async function getTodos() { // ★ To-doタスクを読み込む
    if (!session) {
      return;
    }
    try {
      const { data, error } = await supabase
        .from<Todo>('todos')
        .select('*')
        .eq('user_id', session.user!.id)
        .order('inserted_at', { ascending: true });

      if (!data && error) {
        throw error;
      }
      setTodos(data);
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    }
  }

  const addNewTodo = async (newTask: string) => { // ★ To-doタスクの追加処理
    if (!session) {
      return;
    }
    if (newTask.length <= 3) {
      enqueueSnackbar('4文字以上にしてください。', { variant: 'warning' });
      return;
    }
    try {
      const { data, error } = await supabase
        .from<Todo>('todos')
        .insert({ task: newTask, user_id: session.user!.id })
        .single();
      setTodos([...todos, data!]);
      setNewTask('');
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    } 
  };

  return (
    <>
      <h1>やること</h1>
      <div className='flex'>
        <input
          type='text'
          placeholder='やることを追加してください。'
          value={newTask}
          className='grow'
          onChange={(e) => setNewTask(e.target.value)}
        />

        <button
          className='button primary flex-none'
          onClick={() => addNewTodo(newTask)}
        >
          追加
        </button>
      </div>
      <ol>
        {todos &&
          todos.map((todo) => (
            <TodoCard // ★ 各To-do
              key={todo.id}
              todo={todo}
              onDelete={deleteTask}
              onToggleComplete={toggleTaskComplete}
            />
          ))}
      </ol>
    </>
  );
}

TodoCardコンポーネント

// src\components\TodoCard.tsx

import { VscTrash } from 'react-icons/vsc';

export type Todo = { // ★ 各To-doのデータ
  id: number;
  user_id: string;
  task: string;
  is_complete: boolean;
  inserted_at: Date; // ★ 今は使っていない
};

type Props = {
  todo: Todo;
  onDelete: (id: number) => void;
  onToggleComplete: (id: number) => void;
};

export default function TodoCard({ todo, onDelete, onToggleComplete }: Props) {
  return (
    <li className='card' key={todo.id}>
      <div className='flex'>
        {todo.is_complete ? (
          <div className='grow'>
            <del>{todo.task}</del>{' '}
          </div>
        ) : (
          <div className='grow'>{todo.task} </div>
        )}
        <button // ★ [未完了に戻す] ←→ [完了にする]ボタン
          className='button complete-button flex-none'
          onClick={() => onToggleComplete(todo.id)}
        >
          {todo.is_complete ? '未完了に戻す' : '完了にする'}
        </button>
        <button className='button flex-none' onClick={() => onDelete(todo.id)}> // ★ ゴミ箱アイコン
          <VscTrash />
        </button>
      </div>
    </li>
  );
}

Authコンポーネント

ログインとサインアップをおこなうためのAuthコンポーネントを作成します。マジックリングを使うのでパスワードレスです。

// src\components\Auth.tsx

import { useState } from 'react';
import { supabase } from '../utils/supabaseClient';
import { useSnackbar } from 'notistack';

export default function Auth() {
  const [loading, setLoading] = useState(false);
  const [email, setEmail] = useState('');
  const { enqueueSnackbar } = useSnackbar();

  const handleLogin = async (email: string) => { // ★ マジックリンクを送信する処理
    try {
      setLoading(true);
      const { error } = await supabase.auth.signIn({ email }); // ★ マジックリンク送信
      if (error) throw error;
      enqueueSnackbar('リンクのためのメールをご確認ください。', {
        variant: 'info',
      });
    } catch (error: any) {
      enqueueSnackbar(error.error_description || error.message, {
        variant: 'error',
      });
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className='row flex flex-center'>
      <div className='col-6 form-widget'>
        <h1 className='header'>ピータンTO-DO</h1>
        <p className='description'>下記Eメールでマジックリンクでログイン</p>
        <div>
          <input // ★ Eメール入力
            className='inputField'
            type='email'
            placeholder='Eメール'
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <div>
          <button // ★ [マジックリンクを送信] ←→ [読み込み中...]ボタン
            onClick={(e) => {
              e.preventDefault();
              handleLogin(email);
            }}
            className='button block'
            disabled={loading}
          >
            <span>{loading ? '読み込み中...' : 'マジックリンクを送信'}</span>
          </button>
        </div>
      </div>
    </div>
  );
}
  • auth.signIn()についてはこちらに記載があります。
  • 電子メール又はこちらに列挙されるサードパーティのOAuthプロバイダでログインが可能。
  • 電子メールでパスワードが与えられない場合は、マジックリンクが送信される(今回のケース)。
  • マジックリンクはデフォルトで、各ユーザーが60秒に1回までしか送れない。
  • マジックリンクでログイン後のURLは、SITE_URLで設定される。これは、SupabaseのダッシュボードのAuthentificationSettingsで設定できる。

App.tsx の変更

AuthコンポーネントとAccountコンポーネントを利用するようにApp.tsxを次のように変更します。

// src\App.tsx

import './App.css';
import { useState, useEffect } from 'react';
import { supabase } from './utils/supabaseClient';
import Auth from './components/Auth';
import Account from './components/Account';
import { Session } from '@supabase/gotrue-js';
import { SnackbarProvider } from 'notistack';

function App() {
  const [session, setSession] = useState<Session | null>(null);

  useEffect(() => {
    setSession(supabase.auth.session()); // ★ セッションデータを返す

    supabase.auth.onAuthStateChange((_event, session) => { // ★ 認証のイベントを受け取る
      setSession(session);
    });
  }, []);

  return (
    <SnackbarProvider>
      <div className='container' style={{ padding: '50px 0 100px 0' }}>
        {!session ? (
          <Auth /> // ★ アクティブなセッションがない場合
        ) : (
          <Account key={session?.user?.id} session={session} /> // ★ アクティブなセッションがある場合
        )}
      </div>
    </SnackbarProvider>
  );
}

export default App;

App.css の編集

こちらからCSSをコピペします。

そこから多少追加変更しました。

// 変更
.flex {
  display: flex;
  padding-top: 0.5rem;
  padding-bottom: 0.5rem;
  align-items: center;
  gap: 1rem;
}

// 追加
.complete-button {
  width: 8rem;
}

.w-28 {
  width: 7rem;
}

/* React-Toastify */
/* https://fkhadra.github.io/react-toastify/how-to-style/#override-existing-css-classes */
* {
  --toastify-color-light: red;
}

実行

以下のコマンドを実行し、http://localhost:3000 にアクセスします。

$ yarn dev
認証画面

上記画面でメールアドレスを入力し、[マジックリンクを送信]ボタンをクリックすると noreply@mail.app.supabace.io からメールが送信されてきます。

vite.config.jsの変更

vitev3になり、開発サーバーのデフォルトのポートが30005173に変更されました(詳細)。以前のままにするためには次のようにserver.portを設定します。

// vite.config.ts

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vitejs.dev/config/
export default defineConfig({
  server: {
    port: 3000,
  },
  plugins: [react()],
});

デプロイ

動作確認するために、GitHubNetlifyなどを利用してデプロイします。

以下の環境変数の設定を忘れずに。

  • VITE_SUPABASE_URL
  • VITE_SUPABASE_ANON_KEY

また、認証のためにデプロイしたサイトのURLをSupabaseに教える必要があります。

  1. supabaseのダッシュボードのアイコンの並びのAutheticationをクリックする。
  2. サイドバーのSettingsをクリックする。
  3. Site URLを”https://localhost:3000“からデプロイ先のURLに変更する。

Supabaseの認証設定

  • AuthenticationSettings
    • User SessionsSite URLにデプロイ先のURLを設定します。
    • Redirect URLsにデプロイ先のURLを設定します。