Supabase を勉強する
普段は Firebase が好きで使ってるけど、Firestore の構造で詰まることが多いので期待
Udemy で買った講座で Next.js + Supabase のものを試しにやってみる(有料の講座なのでまんまソースをここに書くことはしない。参考にしたり一部を抜粋して載せる程度に抑える)
個人的に気になるトピックス
- Firebase でよく使ってるものの使い心地が気になる
- Auth, Firestore, Storage, Functions, Hosting
- 価格はどうなのか
- セキュリティルールとかはどうなのか
- 設定方法は簡単かどうか
- GraphQLとかと使う時にどうだろうか
- 将来性とかはあるか(FirebaseはGoogleだからまあまあ安心かと、逆に不安とかもあるけど
Web Vitals について
- TTFB
- FCP
- LCP
SSG, ISR, SSR → FCP = LCP
SSG + CSF → FCP < LCP
Next.jsのページタイプ
- SSG
- SSG dataあり
- ISR(on-demand)
- SSR
- SSG + CSF(デプロイ時にはデータフェッチしない)
- SSG + CSF(デプロイ時にもデータフェッチするので、CSF前に古いかもしれないデータが見れる)
- ISR + CSF
vercel だと Edge-cache に HTML やAPIの結果の JSON がキャッシュするので、ページの表示も早いし、CSR でデータを取得して構成するのも早い
Supabase の管理画面を触ってみる
Auth
Email が Enable になっているのでいきなり使えるみたい
設定は Confirm email
と言うのだけ OFF にした(確認メールが飛ばないようにしている感じだと思う)
Database
設定はされているみたいなので、いきなりテーブルを作るところから行けた
直感的なので特に説明はなくてもできそう
id だけ int8
から uuid
にしてデフォルト値を uuid_generate_v4()
にした
これで Firebase みたいに ID が uuid になって便利っぽい
あとリレーションを貼るときはカラム名のところのクリップマークを押して他のテーブルのカラムを選べる
今回は auth の users を選択して、どのユーザーのデータかを紐づけた
ここまでですでに Firestore よりやりやす過ぎて悔しい...
フロントから使う
左サイドメニューの歯車から API
を選んで Project API keys
の public
のトークンを Next.js の設定に反映する
Next.js の初期セットアップ
プロジェクトを作る
npx create-next-app [アプリ名] --typescript
その他、関連するライブラリをインストール
npm i @heroicons/react @supabase/supabase-js react-query zustand
npm i -D tailwindcss postcss autoprefixer
npm i -D prettier prettier-plugin-tailwindcss
↓tailwindcss のセットアップ
Next.js に Supabase をセットアップする
ディレクトリの直下に .env.local
を作り以下の内容を記述する
NEXT_PUBLIC_SUPABASE_URL=[管理画面のProject URLの値]
NEXT_PUBLIC_SUPABASE_API_KEY=[管理画面のProject API keysの値]
セットアップ用のファイルを作る
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL as string,
process.env.NEXT_PUBLIC_SUPABASE_API_KEY as string
);
今回作ったテーブルの型を定義する
export type Task = {
id: string;
created_at: string;
title: string;
user_ui: string | undefined;
};
export type Notice = {
id: string;
created_at: string;
content: string;
user_ui: string | undefined;
};
SSG で Supabase のデータを取得する
とてもシンプルで良き
処理
import { NextPage, GetStaticProps } from 'next';
import { Layout } from '@/components/Layout';
import { supabase } from '@/utils/supabase';
import { Task, Notice } from '@/types/types';
export const getStaticProps: GetStaticProps = async () => {
const { data: tasks } = await supabase
.from('todos')
.select('*')
.order('created_at', { ascending: true });
const { data: notices } = await supabase
.from('notices')
.select('*')
.order('created_at', { ascending: true });
return { props: { tasks, notices } };
};
type StaticProps = {
tasks: Task[];
notices: Notice[];
};
const Ssg: NextPage<StaticProps> = ({ tasks, notices }) => {
return (
<Layout title="SSG">
<p className="mb-3 text-blue-500">SSG</p>
<ul className="mb-3">
{tasks.map((task) => {
return (
<li key={task.id}>
<p className="text-lg font-extrabold">{task.title}</p>
</li>
);
})}
</ul>
<ul className="mb-3">
{notices.map((notice) => {
return (
<li key={notice.id}>
<p className="text-lg font-extrabold">{notice.content}</p>
</li>
);
})}
</ul>
</Layout>
);
};
export default Ssg;
SSR で Supabase のデータを取得する
SSR は GetServerSideProps
にするだけでOK
処理
import { NextPage, GetServerSideProps } from 'next';
import { Layout } from '@/components/Layout';
import { supabase } from '@/utils/supabase';
import { Task, Notice } from '@/types/types';
export const getServerSideProps: GetServerSideProps = async () => {
console.log('getServerSideProps/ssr invoked');
const { data: tasks } = await supabase
.from('todos')
.select('*')
.order('created_at', { ascending: true });
const { data: notices } = await supabase
.from('notices')
.select('*')
.order('created_at', { ascending: true });
return { props: { tasks, notices } };
};
type StaticProps = {
tasks: Task[];
notices: Notice[];
};
const Ssr: NextPage<StaticProps> = ({ tasks, notices }) => {
return (
<Layout title="SSG">
<p className="mb-3 text-blue-500">SSR</p>
<ul className="mb-3">
{tasks.map((task) => {
return (
<li key={task.id}>
<p className="text-lg font-extrabold">{task.title}</p>
</li>
);
})}
</ul>
<ul className="mb-3">
{notices.map((notice) => {
return (
<li key={notice.id}>
<p className="text-lg font-extrabold">{notice.content}</p>
</li>
);
})}
</ul>
</Layout>
);
};
export default Ssr;
CSR(SSG + CSF) で Supabase のデータを取得する
処理
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { NextPage } from 'next';
import { Layout } from '@/components/Layout';
import { supabase } from '@/utils/supabase';
import { Task, Notice } from '@/types/types';
const Csr: NextPage = () => {
const router = useRouter();
const [tasks, setTasks] = useState<Task[]>([]);
const [notices, setNotices] = useState<Notice[]>([]);
useEffect(() => {
const getTasks = async () => {
const { data: tasks } = await supabase
.from('todos')
.select('*')
.order('created_at', { ascending: true });
setTasks(tasks as Task[]);
};
const getNotices = async () => {
const { data: notices } = await supabase
.from('notices')
.select('*')
.order('created_at', { ascending: true });
setNotices(notices as Notice[]);
};
getTasks();
getNotices();
}, []);
return (
<Layout title="CSR">
<p className="mb-3 text-blue-500">SSR + CSF</p>
<ul className="mb-3">
{tasks.map((task) => {
return (
<li key={task.id}>
<p className="text-lg font-extrabold">{task.title}</p>
</li>
);
})}
</ul>
<ul className="mb-3">
{notices.map((notice) => {
return (
<li key={notice.id}>
<p className="text-lg font-extrabold">{notice.content}</p>
</li>
);
})}
</ul>
<Link href="/ssr" prefetch={false}>
<span className="mb-3 text-xs">Link to ssr</span>
</Link>
<Link href="/isr" prefetch={false}>
<span className="mb-3 text-xs">Link to isr</span>
</Link>
<button className="mb-3 text-xs" onClick={() => router.push('/ssr')}>
Route to ssr
</button>
</Layout>
);
};
export default Csr;
ISR で Supabase のデータを取得する
基本は SSG と同じで getStaticProps
を使う
その戻り値に revalidate
というのを設定するだけで良い(Vercelなどの対応しているサーバー前提)
revalidate
はキャッシュの検証をするまでの時間を設定するので 5
としていれば5秒間はキャッシュを返し続けることになる(キャッシュが作られてから5秒間はキャッシュを使う)
5秒後からはキャッシュが古いとみなされて新しいキャッシュが作られる
処理
import Link from 'next/link'; import { useRouter } from 'next/router';
import { NextPage, GetStaticProps } from 'next';
import { Layout } from '@/components/Layout';
import { supabase } from '@/utils/supabase';
import { Task, Notice } from '@/types/types';
export const getStaticProps: GetStaticProps = async () => {
console.log('getStaticProps/isr invoked');
const { data: tasks } = await supabase
.from('todos')
.select('*')
.order('created_at', { ascending: true });
const { data: notices } = await supabase
.from('notices')
.select('*')
.order('created_at', { ascending: true });
return { props: { tasks, notices }, revalidate: 5 };
};
type StaticProps = {
tasks: Task[];
notices: Notice[];
};
const Isr: NextPage<StaticProps> = ({ tasks, notices }) => {
const router = useRouter();
return (
<Layout title="ISR">
<p className="mb-3 text-indigo-500">ISR</p>
<ul className="mb-3">
{tasks.map((task) => {
return (
<li key={task.id}>
<p className="text-lg font-extrabold">{task.title}</p>
</li>
);
})}
</ul>
<ul className="mb-3">
{notices.map((notice) => {
return (
<li key={notice.id}>
<p className="text-lg font-extrabold">{notice.content}</p>
</li>
);
})}
</ul>
<Link href="/ssr" prefetch={false}>
<span className="mb-3 text-xs">Link to ssr</span>
</Link>
<Link href="/isr" prefetch={false}>
<span className="mb-3 text-xs">Link to isr</span>
</Link>
<button className="mb-3 text-xs" onClick={() => router.push('/ssr')}>
Route to ssr
</button>
</Layout>
);
};
export default Isr;
Next.js の開発環境のビルドについて
npm run dev
でやっていると全てのページが SSR
としてビルドされるので SSG
とかを確かめたい場合は production モードで確かめる必要がある
以下のコマンドで本番用の挙動が確認できる
npm run build
npm run start
こうすると .next/static
のファイルが生成され、これらが Egde Server にキャッシュされるらしい。
.next/static/chunks/pages/
などに SSG、SSR 用の js ファイルがそれぞれ生成される。
SSG のものはデータが含まれたHTMLができている。(JSONも一緒に作られる)
SSR のものはデータが含まれておらず、アクセス時に getServerSideProps
が実行され、データが含まれたHTMLが返される。
Supabase の Auth を使う
仕様
- トップページをログインページとする
- トップページ以外でログインしていなければトップページにリダイレクトする
- ログインした状態でトップページに来た場合はダッシュボードページにリダイレクトする
全ページにログイン状態に合わせてリダイレクトなどをする処理を入れる
import や一部の処理は省略しています
// データをフェッチしたりするための react-query をセットアップ
// 不要なフェッチが走らないように設定をする
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
function App({ Component, pageProps }: AppProps) {
const { push, pathname } = useRouter();
// ログインしていなかったらトップに飛ばす処理
const validateSession = async () => {
const res = await supabase.auth.getUser();
const { user } = res.data;
if (user && pathname === '/') {
push('/dashboard');
} else if (!user && pathname !== '/') {
await push('/');
}
};
// ログイン状態のセッションの変更を検知して動く処理
supabase.auth.onAuthStateChange((event, _) => {
if (event === 'SIGNED_IN' && pathname === '/') {
push('/dashboard');
}
if (event === 'SIGNED_OUT') {
push('/');
}
});
// 初期表示時にチェック
useEffect(() => {
validateSession();
}, []);
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />;
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
export default App;
ログインや新規登録用の処理をまとめる hooks を用意
import { useState } from 'react';
import { supabase } from '@/utils/supabase';
import { useMutation } from 'react-query';
export const useMutateAuth = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const reset = () => {
setEmail('');
setPassword('');
};
const loginMutaion = useMutation(
async () => {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw new Error(error.message);
},
{
onError: (err: any) => {
alert(err.message);
reset();
},
}
);
const registerMutaion = useMutation(
async () => {
const { error } = await supabase.auth.signUp({
email,
password,
});
if (error) throw new Error(error.message);
},
{
onError: (err: any) => {
alert(err.message);
reset();
},
}
);
return {
email,
setEmail,
password,
setPassword,
loginMutaion,
registerMutaion,
};
};
トップページ(Auth)に UI を作ってログインや新規登録を行う
import など一部、省略しています。
/**
* トップページ
* ログイン、新規会員登録ページ
*/
const Auth: NextPage = () => {
const [isLogin, setIsLogin] = useState(true);
const {
email,
setEmail,
password,
setPassword,
loginMutaion,
registerMutaion,
} = useMutateAuth();
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isLogin) {
loginMutaion.mutate();
} else {
registerMutaion.mutate();
}
};
return (
<Layout title="Auth">
<ShieldCheckIcon className="mb-6 h-12 w-12 text-blue-500" />
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
required
className="my-2 rounded border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-indigo-500 focus:outline-none"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<input
type="password"
required
className="my-2 rounded border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-indigo-500 focus:outline-none"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="my-6 flex items-center justify-center text-sm">
<button
type="button"
onClick={() => setIsLogin(!isLogin)}
className="cursor-pointer font-medium hover:text-indigo-500"
>
change mode ?
</button>
</div>
<button
type="submit"
className="group relative flex w-full justify-center rounded-md bg-indigo-600 py-2 px-4 text-sm font-medium text-white hover:bg-indigo-700"
>
<span className="absolute inset-y-0 left-0 flex items-center pl-3">
<CheckBadgeIcon className="h-5 w-5" />
</span>
{isLogin ? 'Login' : 'Register'}
</button>
</form>
</Layout>
);
};
export default Auth;
:::
Next.js の 型定義について
型定義をどこに書くか問題が気になっていたが、講座が結構いい感じな気がするので今後もこの方法にしたい
こんな感じでアプリケーション全体で使うような型はまとめて定義して、各ページでしか使わない props などはそのファイル内に定義するのが良さそう
export type Task = {
id: string;
created_at: string;
title: string;
user_ui: string | undefined;
};
export type Notice = {
id: string;
created_at: string;
content: string;
user_ui: string | undefined;
};
export type EditedTask = Omit<Task, 'created_at' | 'user_id'>;
export type EditedNotice = Omit<Notice, 'created_at' | 'user_id'>;
↑ちなみにこの Omit
は特定のオブジェクト型の型のプロパティを除外した型が作れるみたい
上の例では EditedTask
は Task
から created_at
と user_id
を除外したものになる
Zustand を使って状態を管理する
更新中のタスクなどを管理するために Zustand (ザスタンド、チュースタンド)を使う
Zustand のセットアップ
import { create } from 'zustand';
import { EditedTask, EditedNotice } from '@/types/types';
type State = {
editedTask: EditedTask;
editedNotice: EditedNotice;
updateEditedTask: (payload: EditedTask) => void;
updateEditedNotice: (payload: EditedNotice) => void;
resetEditedTask: () => void;
resetEditedNotice: () => void;
};
const useStore = create<State>((set) => ({
editedTask: { id: '', title: '' },
editedNotice: { id: '', content: '' },
updateEditedTask: (payload) => {
set({
editedTask: {
id: payload.id,
title: payload.title,
},
});
},
updateEditedNotice: (payload) => {
set({
editedNotice: {
id: payload.id,
content: payload.content,
},
});
},
resetEditedTask: () => set({ editedTask: { id: '', title: '' } }),
resetEditedNotice: () => set({ editedNotice: { id: '', content: '' } }),
}));
export default useStore;
react-query
を使う
データの取得、追加、更新、削除は 一度取得したデータは react-query のキャッシュに保存される。
データの変更が起きるたびに全件取ってくるのではなく、キャッシュを更新してそれを使う
データの追加、更新の時のデータを Zustand に保存して扱っている
取得の処理
staleTime
を Infinity
にするとキャッシュはずっと有効になり、初回実行時以降勝手にデータを取りに行ったりしない。その代わりにキャッシュの更新はデータ更新時にフロント側でやる。
タスクは非同期で勝手に更新されたりしないデータなので、画面表示時など useQueryTasks
が実行されたときにちゃんと新しいデータが取得されるだけで良いのでこうしている
お知らせなどユーザー以外がデータを更新した時にそれを更新したい時にはこれを設定すると良さそう
import { useQuery } from 'react-query';
import { supabase } from '@/utils/supabase';
import { Task } from '@/types/types';
/**
* todoリストを取得するhooks
*/
export const useQueryTasks = () => {
const getTasks = async () => {
const { data, error } = await supabase
.from('todos')
.select('*')
.order('created_at', { ascending: true });
if (error) {
throw new Error(error.message);
}
return data;
};
return useQuery<Task[], Error>({
queryKey: ['todos'],
queryFn: getTasks,
staleTime: Infinity,
});
};
Mutation を作って追加、更新、削除をする
追加、更新、削除時に react-query のキャッシュを更新する
import { useQueryClient, useMutation } from 'react-query';
import useStote from '@/stores';
import { supabase } from '@/utils/supabase';
import { Task, EditedTask } from '@/types/types';
export const useMutateTask = () => {
const queryClient = useQueryClient();
const reset = useStote((state) => state.resetEditedTask);
// タスクを作成する
const createTaskMutation = useMutation(
async (task: Omit<Task, 'id' | 'created_at'>) => {
const { data, error } = await supabase
.from('todos')
.insert(task)
.select();
if (error) throw new Error(error.message);
return data;
},
{
/**
* 処理に成功した場合、キャッシュのデータを取得して
* キャッシュが存在すれば後ろに追加する
*/
onSuccess: (res) => {
const previousTodos = queryClient.getQueryData<Task[]>('todos');
if (previousTodos && res != null) {
queryClient.setQueryData('todos', [...previousTodos, res[0]]);
}
reset();
},
onError(err: any) {
alert(err.message);
reset();
},
}
);
// タスクを更新する
const updateTaskMutation = useMutation(
async (task: EditedTask) => {
const { data, error } = await supabase
.from('todos')
.update({ title: task.title })
.eq('id', task.id)
.select();
if (error) throw new Error(error.message);
console.log(data);
return data;
},
{
/**
* 処理に成功した場合、キャッシュのデータを取得して
* キャッシュが存在すればデータを入れ替える
*/
onSuccess: (res, variables) => {
const previousTodos = queryClient.getQueryData<Task[]>('todos');
if (previousTodos && res != null) {
const newTodos = previousTodos.map((task) =>
task.id === variables.id ? res[0] : task
);
queryClient.setQueryData('todos', newTodos);
}
reset();
},
onError(err: any) {
alert(err.message);
reset();
},
}
);
// タスクを削除する
const deleteTaskMutation = useMutation(
async (id: string) => {
const { data, error } = await supabase
.from('todos')
.delete()
.eq('id', id);
if (error) throw new Error(error.message);
return data;
},
{
/**
* 処理に成功した場合、キャッシュのデータを取得して
* キャッシュが存在すればデータを削除する
*/
onSuccess: (_, variables) => {
const previousTodos = queryClient.getQueryData<Task[]>('todos');
if (previousTodos) {
queryClient.setQueryData(
'todos',
previousTodos.filter((task) => task.id !== variables)
);
}
reset();
},
onError(err: any) {
alert(err.message);
reset();
},
}
);
return {
createTaskMutation,
updateTaskMutation,
deleteTaskMutation,
};
};
UI部分
一部省略してます
const Dashboard: NextPage = () => {
const signOut = () => {
supabase.auth.signOut();
};
return (
<Layout title="Dashboard">
<ArrowLeftOnRectangleIcon
className="text-blur-500 mb-6 h-6 w-6 cursor-pointer text-blue-500"
onClick={signOut}
/>
<div className="grid grid-cols-2 gap-40">
<div>
<div className="my-3 flex justify-center">
<DocumentTextIcon className="h-8 w-8 text-blue-500" />
</div>
<TaskForm />
<TaskList />
</div>
</div>
</Layout>
);
};
export default Dashboard;
export const TaskForm: FC = () => {
const { editedTask } = useStore();
const update = useStore((state) => state.updateEditedTask);
const { createTaskMutation, updateTaskMutation } = useMutateTask();
const submitHandler = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (editedTask.id === '') {
// ID がなければ新規登録
const res = await supabase.auth.getUser();
const { user } = res.data;
createTaskMutation.mutate({
title: editedTask.title,
user_id: user?.id,
});
} else {
// ID があれば更新
updateTaskMutation.mutate({
id: editedTask.id,
title: editedTask.title,
});
}
};
return (
<form onSubmit={submitHandler}>
<input
type="text"
className="rounted my-2 border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-indigo-500 focus:outline-none"
placeholder="New task ?"
value={editedTask.title}
onChange={(e) => update({ ...editedTask, title: e.target.value })}
/>
<button className="ml-2 rounded bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700">
{editedTask.id ? 'Update' : 'Create'}
</button>
</form>
);
};
export const TaskItem: FC<Omit<Task, 'created_at' | 'user_id'>> = ({
id,
title,
}) => {
const update = useStore((state) => state.updateEditedTask);
const { deleteTaskMutation } = useMutateTask();
return (
<li className="my-3 text-lg font-extrabold">
<span>{title}</span>
<div className="float-right ml-20 flex">
<PencilIcon
className="mx-1 h-5 w-5 cursor-pointer text-blue-500"
onClick={() => update({ id, title })}
/>
<TrashIcon
className="h-5 w-5 cursor-pointer text-blue-500"
onClick={() => deleteTaskMutation.mutate(id)}
/>
</div>
</li>
);
};
export const TaskList: FC = () => {
const { data: tasks, status } = useQueryTasks();
if (status === 'loading') return <Spinner />;
if (status === 'error') return <p>{'Error'}</p>;
return (
<ul className="my-2">
{tasks?.map((task) => (
<TaskItem key={task.id} id={task.id} title={task.title} />
))}
</ul>
);
};
RLS(Row Level Security)を設定する
準備
- 管理画面のテーブルのの右上から
RLS is not enabled
を選択 - 有効にしたいテーブルの
Enable RLS
→Confirm
をクリック- 有効にしただけだと全てのアクセスができない状態になる
- 取得も追加もできない
New Policy
をクリックして追加していく
ポリシーを変更したいテーブルで -
Get started queckly
→ テンプレートから選べる -
For full customization
→ 自分で一からポリシーを書く
ログインユーザーしか insert できない設定
-
Enable insert access for authenticated users only
(追加に関する設定)を選択- 右下の
Use this template
を選択 -
INSERT
のTarget roles
がauthenticated
になっていて、WITH CHECK expression
がtrue
になっている状態でReview
をクリック(ログインしているユーザーのみ追加ができるという設定) -
Save policy
をクリックして完了
- 右下の
同じ user_id のデータしか delete できない設定
-
Enabled delete access for users based on their user ID
(削除に関する設定)を選択- 全てそのまま
Review
とSave policy
で同じ user ID のユーザーだけ削除できるように設定ができた
- 全てそのまま
同じ user_id のデータしか update できない設定
- 更新に関する設定をするが、これも
Enabled delete access for users based on their user ID
(削除に関する設定)を選択-
Policy name
のdelete
をupdate
に書き換えてEnable update for users based on user_id
とする -
Allowed oparation
のUPDATE
を選択する -
USING expression
とWITH CHECK expression
を同じ記述(auth.uid() = user_id
)にして、セーブする
-
同じ user_id のデータしか select できない設定
- 選択に関する設定をするが、これも
Enabled delete access for users based on their user ID
(削除に関する設定)を選択-
Policy name
のdelete
をselect
に書き換えてEnable select for users based on user_id
とする -
Allowed oparation
のSELECT
を選択する - 中身はそのまま delete と同じにしてセーブする
-
本来は where 句とかで絞り込みをすると思うけど、それをポリシーで設定しておけば select *
とした時も、このポリシーによって絞り込みがなされる感じ
ポリシーは where 句を追加するようなイメージなのかも
ログインしてれば全てのデータを select できる設定
上記とは別に、閲覧だけはさせたい時とかに使うやつっぽい
-
Enable insert access for authenticated users only
(追加に関する設定)を選択-
Policy name
のinsert
をselect
に変えてEnable select for authenticated users only
とする -
Allowed oparation
のSELECT
を選択する -
USING expression
はそのままtrue
としておく
-
↑ログインしていないとデータが取得できないようにしたので、SSRなどサーバー側でデータを取得する際にはログイン情報が取得できず、データが空の配列になって見れなくなったはず
ユーザーの認証はクライアント側の JWTトークン
を使っているので、こういうことが起きる
サーバー側で認証トークンを付与することもできるが、別途コードをセットする必要がある。
ログインしていなくてもだれでも見れるようにする
-
For full customization
の方を選択-
Policy name
をEnable select for anonymous users
とする -
Allowed oparation
のSELECT
を選択する -
USING expression
をtrue
としておく
-
JWT とは
JSON Web Tokenの略称
発音は"ジョット"
ジェーダブリューティーって呼んでた...
JWTは2つのピリオド(".")で区切られた3つのパートによって成り立っている
この3つのパートにはそれぞれ役割があり、前から順番にヘッダー(Header)、ペイロード(Payload)、署名(Sinature)となっています。
<ヘッダー>.<ペイロード>.<署名>
ON DELETE CASCADE
を設定する
notes テーブルの id と comments テーブルの note_id をリレーションさせたい
そして notes のレコードが消えた時にそこに紐づくコメントも一緒に削除してほしい(ON DELETE CASCADE)のでその設定をする
- 特にリレーションは貼らずにそれぞれのテーブルを作る
- その設定画面では設定ができないので左サイドバーの
SQL Editor
からNew query
を選択する - この画面では SQL が実行できるのでそこでリレーションを貼る用の以下のコマンドを実行する
ALTER TABLE public.comments
ADD CONSTRAINT comments_note_id_fkey
FOREIGN KEY (note_id)
REFERENCES notes(id)
ON DELETE CASCADE;
意味は大体以下みたいな感じ
-
ALTER TABLE public.comments
-
comments
テーブルの定義を変更するよ
-
-
ADD CONSTRAINT comments_note_id_fkey
-
comments_note_id_fkey
と言う名前の制約を追加するよ
-
-
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
-
note_id
を外部キーとして、notes テーブルの id を参照するようにして、参照元のデータが削除された時には一緒に削除するよ
-
成功したら以下の表示が出る
Success. No rows returned
Table editor
に戻って、 comments
テーブルの Edit table
を選択すると note_id
のところのリファレンスアイコンが白くハイライトされている状態になっていればOK
開発の流れについて
可能なら先に Supabase の設定を全て終わらせてから開発に進めると良さそう
Authの設定
-
Tableの構築
(テーブルを作成する、リレーションを貼る、RLS を設定する) -
TypeScriptの型定義を作る
(テーブル構造が明確なのでそれと同じ型定義をこの時点で作る)
データの取得とかもこれで挙動が変わるのでアプリ開発より先にやった方が良い。
また、テストとかを書く時とかにも、どのユーザーでどうなるのかとかを把握した状態で書けるから良さそう。
Firebase との違いについて
まだ全然理解できてないことも多いけど、ここまで調べて一旦思ったこと
Auth
特に使い勝手に差はないかなと言う印象
基本的にはクライアント側でしか動いてないと言う Firebase と同じような使い勝手
関係ないけど、いつも独自の hooks を用意してたのを react-query
で書いてみて、便利だなーという印象
Database
当たり前だけどしっかり RDB 理解してないと難しいなって感じがした。
Firestore を触ってると「正規化したいなー」って思う瞬間があるので、そういう場合は最適かなと思いつつ NoSQL の手軽さはないので、そこのバランスなのかなという印象
user_id とかをリレーション貼るのがめっちゃ楽だったのでこれはめっちゃメリットな気がする。
あちこちリレーション貼って正規化して効率的にデータを扱うならもちろん Supabase が良いけど、ライトな感じでデータをサーバー側に格納しておきたいだけなんだよなって感じなら Firestore でも全然いけるなという気がした。
Firebase だとスクリプト書いてローカルで動かしたりしてたので、SQL Editor でデータを操作したりできるのはいいなって感じた
補完もめっちゃ聞くので SQL が大体わかってれば簡単に動かせる
そして、おそらくそのクエリを保存して置けるので、また必要になったらすぐに呼び出すことができる
これをソースコードとかドキュメントじゃなくて管理画面で管理できるのいいなーって思った
セキュリティルールについて
RLS は結構書きやすいなという印象
テーブルごとに設定ができて、誰でも見れるとか、ログインユーザーだけとか、同じ user_id の人しか追加、更新、削除ができないとかも GUI でぽちぽちでいけたので、Firebase のセキュリティルールよりはお手軽な印象
Firestore のセキュリティルールが僕自身あんまり得意じゃないだけかもですが、user_id 周りの連携がしやすいのが書きやすさの違いなのかな?という気がした
On-Demand ISR で更新される Note アプリを作る
On-Demand ISR とは
通常の ISR はキャッシュの有効期限を決めてアクセスがくるたびにキャッシュがなければ作り直す方式。
On-Demand ISR はこちらの任意のタイミングで再生成を行う方式
Next.js + Vercel 前提だけど通常の ISR だと getStaticProps
の revalidate
にキャッシュの有効期限を入れることで実装できる。
今回は関連する情報が更新されるたびに再生成を行うようにする方法を学ぶ
イメージとしては、再生成をさせるための res.revalidate()
関数を情報が更新されるたびに叩くイメージ
Next.js のセットアップ
Next.js のセットアップ
初期セットアップコマンド
npx create-next-app supabase-note-app
npm i @heroicons/react @supabase/supabase-js react-query zustand
npm i -D tailwindcss postcss autoprefixer
npm i -D prettier prettier-plugin-tailwindcss
tailwindcss のセットアップ
npx tailwindcss init -p
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx}",
"./src/components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
};
@tailwind base;
@tailwind components;
@tailwind utilities;
最後に、不要なので src/styles/Home.module.css
を削除
Prettier のセットアップ
個人的には以下の設定は最低限入れておきたいのでファイルを作って設定
{
"singleQuote": true,
"semi": true
}
Supabase の設定
REVALIDATE_SECRET
は ISR のために Supabase の Webhooks というところで使う
中身は任意のパスワードの乱数ならなんでもOK
NEXT_PUBLIC
という接頭語で作った環境変数はクライアント側で使うもので、ビルド時に書き出される static のファイルに載ってしまうユーザーにも見えてしまう。
ユーザーに見せたくないものは上記の接頭語を使わずに定義すれば良いが、こうするとクライアントで実行した際の process.env
から読み取れなくなるので注意。
getStaticProps
や getServerSideProps
や pages/api
に配置した API などのサーバー側で動く時にはこちらも参照できる
NEXT_PUBLIC_SUPABASE_URL=[管理画面のProject URLの値]
NEXT_PUBLIC_SUPABASE_API_KEY=[管理画面のProject API keysの値]
REVALIDATE_SECRET=[任意のパスワードを適当に]
On-Demand ISR を実行するための API を立てる
pages/api
に API を立てて、それを叩かれたら特定のページを再生しする処理が走るようにする
res.revalidate(ページのパス);
とすることで、その対象のページが再生成される。
APIを立てる
以下は /notes
ページを再生成するための API
import type { NextApiRequest, NextApiResponse } from 'next';
type Data = {
revalidated: boolean;
};
export default async function handler(
_: NextApiRequest,
res: NextApiResponse<Data>
) {
console.log('Revalidating notes pages...');
let revalidated = false;
try {
await res.revalidate('/notes');
revalidated = true;
} catch (e) {
console.log(e);
}
res.status(200).json({ revalidated });
}
以下は /note/[id]
ページを再生成するための API
import type { NextApiRequest, NextApiResponse } from 'next';
type Data = {
revalidated: boolean;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
console.log('Revalidating detail pages...');
const {
query: { id },
} = req;
let revalidated = false;
try {
await res.revalidate(`/note/${id}`);
revalidated = true;
} catch (e) {
console.log(e);
}
res.status(200).json({ revalidated });
}
API を叩くための utils 関数を定義
上記で作った API を叩くための関数
これをDB更新時に叩くようにする
utils に汎用的な関数を定義
export const revalidateList = () => {
fetch('/api/revalidate');
};
export const revalidateSingle = (id: string) => {
fetch(`/api/revalidate/${id}`);
};
useMutaion を作ってデータを更新する
データの追加、更新、削除に成功したタイミングで revalidateList
や revalidateSingle
を叩いている。
関連するページだけ ISR されれば良いので、そこは更新したデータがどのページに影響があるかを考えて追加する
これでデータが更新されると裏で再生性が走るので、画面を更新したり、再度訪れた時に表示が更新されている
データを更新しながら ISR を実行する処理を叩く
import { useMutation } from 'react-query';
import { supabase } from '@/utils/supabase';
import useStore from '@/stores';
import { revalidateList, revalidateSingle } from '@/utils/revalidation';
import { Note, EditedNote } from '@/types/types';
export const useMutateNote = () => {
const reset = useStore((state) => state.resetEditedNote);
const createNoteMutation = useMutation(
async (note: Omit<Note, 'created_at' | 'id' | 'comments'>) => {
const { error } = await supabase.from('notes').insert(note);
if (error) throw new Error(error.message);
},
{
onSuccess: () => {
revalidateList(); // 一覧ページのISRのためのAPIを実行
reset();
alert('Successfully completed !!');
},
onError: (err: any) => {
alert(err.message);
reset();
},
}
);
const updateNoteMutation = useMutation(
async (note: EditedNote) => {
const { data, error } = await supabase
.from('notes')
.update({ title: note.title, content: note.content })
.eq('id', note.id)
.select();
if (error) throw new Error(error.message);
return data;
},
{
onSuccess: (res) => {
revalidateList(); // 一覧ページのISRのためのAPIを実行
// revalidateSingle(res[0].id); // 個別ページのISRのためのAPIを実行
reset();
alert('Successfully completed !!');
},
onError: (err: any) => {
alert(err.message);
reset();
},
}
);
const deleteNoteMutation = useMutation(
async (id: string) => {
const { error } = await supabase.from('notes').delete().eq('id', id);
if (error) throw new Error(error.message);
},
{
onSuccess: () => {
revalidateList(); // 一覧ページのISRのためのAPIを実行
reset();
alert('Successfully completed !!');
},
onError: (err: any) => {
alert(err.message);
reset();
},
}
);
return { createNoteMutation, updateNoteMutation, deleteNoteMutation };
};
コンポーネント側で useMutation を実行する
こんな感じで updateNoteMutation
や createNoteMutation
を叩く
実際に更新するページ側
一部、省略してます
export const NoteForm: FC = () => {
const { editedNote } = useStore();
const update = useStore((state) => state.updateEditerdNote);
const { createNoteMutation, updateNoteMutation } = useMutateNote();
const submitHandler = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (editedNote.id === '') {
const res = await supabase.auth.getUser();
const { user } = res.data;
createNoteMutation.mutate({
title: editedNote.title,
content: editedNote.content,
user_id: user?.id,
});
} else {
updateNoteMutation.mutate({
id: editedNote.id,
title: editedNote.title,
content: editedNote.content,
});
}
};
if (updateNoteMutation.isLoading || createNoteMutation.isLoading) {
return <Spinner />;
}
return (
<form onSubmit={submitHandler}>
<div>
<input
type="text"
className="my-2 rounded border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-indigo-500 focus:outline-none"
placeholder="title"
value={editedNote.title}
onChange={(e) => update({ ...editedNote, title: e.target.value })}
/>
</div>
<div>
<textarea
cols={50}
rows={10}
className="my-2 rounded border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-indigo-500 focus:outline-none"
value={editedNote.content}
onChange={(e) => update({ ...editedNote, content: e.target.value })}
/>
</div>
<div className="my-2 flex justify-center">
<button
type="submit"
className="ml-2 rounded bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
{editedNote.id ? 'Update' : 'Create'}
</button>
</div>
</form>
);
};
getStaticPaths で動的ルーティングの静的ページを生成しておく
getStaticPaths
はビルド時に呼ばれて、 [id].tsx
などと指定した動的ページのページをビルドをすることができる
paths
で存在するページのパスを返し、fallback
で事前ビルドしたページ以外にアクセスした時の挙動を変えられる。
false
の場合は存在しないページに来たら 404 になり、true
にしておくと SSR のような動きになってページを表示しようとする。
blocking
にした場合は、ページ生成が完了するまでクライアントの操作はブロックされる。(生成できなかったら500になり、見た目は404になる)
パスを取得して返す例
// 全ページのidを取得する
const getAllNoteIds = async () => {
const { data: ids } = await supabase.from('notes').select('id');
return ids!.map((id) => {
return { params: { id: String(id.id) } };
});
};
export const getStaticPaths: GetStaticPaths = async () => {
const paths = await getAllNoteIds();
return {
paths,
fallback: 'blocking',
};
};
Webhooks から API を叩く
API 側にシークレットキーがないと動かないように制御を入れる
ここのシークレットキーは .env.local
に設定した REVALIDATE_SECRET
のやつ
リクエストのにこの乱数を入れないと処理がされないようにした
加えて、Webhooks から叩くようにしたので useMutateNote.ts
でデータの更新後に叩いていた API は叩かないようにした。
import type { NextApiRequest, NextApiResponse } from 'next';
type Data = {
revalidated: boolean;
};
type Msg = {
message: string;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data | Msg>
) {
console.log('Revalidating notes pages...');
// シークレットキーがないと処理しない
if (req.query.secret !== process.env.REVALIDATE_SECRET) {
return res.status(401).json({ message: 'Your secret is invalid !' });
}
let revalidated = false;
try {
await res.revalidate('/notes');
revalidated = true;
} catch (e) {
console.log(e);
}
res.status(200).json({ revalidated });
}
あとは、サイトをデプロイして Vercel の環境変数に NEXT_PUBLIC_SUPABASE_URL
, NEXT_PUBLIC_SUPABASE_API_KEY
, REVALIDATE_SECRET
を設定する。
Supabase の管理画面で Database
から Webhooks
を選択して以下の設定を行う
-
Name
は任意、今回はRevalidate_notes_pages
-
Conditions to fire hook
でTable
をnotes
を選択- どのテーブルに変化があったら動くかの設定
-
Events
はInsert
,Update
,Delete
の全てを選択- 対象のテーブルにどんな変化があれば動くかの設定
-
Type of hook
をHTTP Request
を選択 -
HTTP Request
-
Methods
をPOST
にする -
URL
をデプロイしたURLのAPIに向ける(https://~~~~/api/revalidate
)
-
-
HTTP Params
にsecret
と言う名前でREVALIDATE_SECRET
に設定した乱数を設定
これで、notes
テーブルに追加、更新、削除がおきる度に Webhooks で API が叩かれる
設定した secret
の値が一致するはずなので、処理は実行される
それ以外の方法で叩いたとしても secret
がなければ起動しないので勝手に ISR が叩かれることはない
T3 Stack でセットアップしてみたので記事にしました。