Supabaseに入門しました
概要
オープンソースでFirebaseの代替として人気のあるsupabaseをちょっと試してみました(チュートリアル+α)。
機能として以下があります。
- データベース (Postgres)
- 認証
- ストレージ
- エッジファクション
個人開発にはとても良さそうです。ただ無料プランだと1週間使わないと停止するそうです(どういうことかよくわかっていない。料金プラン参照)。
認証(パスワードレスのマジックリンク)付きでデータベース、プロフィール画像はストレージに保存する次のようなtodoアプリを作成しました。
開発環境の構築には、Vite、React、TypeScriptを採用しました。
サインイン及びプロジェクトの開始
[Sign In with GitHub]ボタンから簡単におこなえます。
データベーススキーマの設定
2種類の方法にて、todos
テーブルとprofiles
テーブルを生成します。まずは、todos
テーブルを作成します。
- supabaseのダッシュボードのアイコンの並びからSQL Editorセクションに移動する。
- Quick startに
Todo List
というスターターがあるのでクリックする。 - [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、行単位のセキュリティ)を有効に変更しています。- RLSはPostgresのポリシーを書くことによってテーブルへのアクセスを制限します。
- RLSが有効でない場合は、anonキーを持つ誰もがあなたのデータを変更及び削除することができます。
create policy
は、テーブルに新しい行単位のセキュリティポリシーを定義します。
続いてprofiles
テーブルを生成します。
- supabaseのダッシュボードのアイコンの並びからSQL Editorセクションに移動する。
- [+ New query]ボタンをクリックする。
- 右の編集画面に下記SQLを貼り付けて[Run]ボタンをクリックする。
- サイドバーに示されている
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をクリックすると確認できます。
APIキーの取得
- supabaseのダッシュボードのアイコンの並びからSettingsセクションに移動する。
- サイドバーのAPIをクッリクする。
以下などが確認できます。
Project URL
: データベースのクエリ及び管理のためのRESTfulエンドポイントProject API keys
: APIはAPIゲートウェイで保護されていて各リクエストで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 URL
とanon
の値を設定します。
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: React
(Bonus: 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を使用します。
$ 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のダッシュボードのAuthentification
のSettings
で設定できる。
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の変更
viteがv3
になり、開発サーバーのデフォルトのポートが3000
か5173
に変更されました(詳細)。以前のままにするためには次のように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()],
});
デプロイ
動作確認するために、GitHubやNetlifyなどを利用してデプロイします。
以下の環境変数の設定を忘れずに。
VITE_SUPABASE_URL
VITE_SUPABASE_ANON_KEY
また、認証のためにデプロイしたサイトのURLをSupabaseに教える必要があります。
- supabaseのダッシュボードのアイコンの並びのAutheticationをクリックする。
- サイドバーのSettingsをクリックする。
- Site URLを”
https://localhost:3000
“からデプロイ先のURLに変更する。
Supabaseの認証設定
Authentication
のSettings
User Sessions
のSite URL
にデプロイ先のURLを設定します。Redirect URLs
にデプロイ先のURLを設定します。
ディスカッション
コメント一覧
まだ、コメントがありません