Open21

Supabase を勉強する

yuyu

普段は Firebase が好きで使ってるけど、Firestore の構造で詰まることが多いので期待
Udemy で買った講座で Next.js + Supabase のものを試しにやってみる(有料の講座なのでまんまソースをここに書くことはしない。参考にしたり一部を抜粋して載せる程度に抑える)

https://supabase.com/

個人的に気になるトピックス

  • Firebase でよく使ってるものの使い心地が気になる
    • Auth, Firestore, Storage, Functions, Hosting
  • 価格はどうなのか
  • セキュリティルールとかはどうなのか
  • 設定方法は簡単かどうか
  • GraphQLとかと使う時にどうだろうか
  • 将来性とかはあるか(FirebaseはGoogleだからまあまあ安心かと、逆に不安とかもあるけど
yuyu

Web Vitals について

  • TTFB
  • FCP
  • LCP

SSG, ISR, SSR → FCP = LCP
SSG + CSF → FCP < LCP

yuyu

Next.jsのページタイプ

  • SSG
  • SSG dataあり
  • ISR(on-demand)
  • SSR
  • SSG + CSF(デプロイ時にはデータフェッチしない)
  • SSG + CSF(デプロイ時にもデータフェッチするので、CSF前に古いかもしれないデータが見れる)
  • ISR + CSF

vercel だと Edge-cache に HTML やAPIの結果の JSON がキャッシュするので、ページの表示も早いし、CSR でデータを取得して構成するのも早い

yuyu

Supabase の管理画面を触ってみる

Auth

Email が Enable になっているのでいきなり使えるみたい
設定は Confirm email と言うのだけ OFF にした(確認メールが飛ばないようにしている感じだと思う)

Database

設定はされているみたいなので、いきなりテーブルを作るところから行けた
直感的なので特に説明はなくてもできそう

id だけ int8 から uuid にしてデフォルト値を uuid_generate_v4() にした
これで Firebase みたいに ID が uuid になって便利っぽい

あとリレーションを貼るときはカラム名のところのクリップマークを押して他のテーブルのカラムを選べる
今回は auth の users を選択して、どのユーザーのデータかを紐づけた

ここまでですでに Firestore よりやりやす過ぎて悔しい...

フロントから使う

左サイドメニューの歯車から API を選んで Project API keyspublic のトークンを Next.js の設定に反映する

yuyu

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 のセットアップ
https://tailwindcss.com/docs/guides/nextjs

yuyu

Next.js に Supabase をセットアップする

ディレクトリの直下に .env.local を作り以下の内容を記述する

NEXT_PUBLIC_SUPABASE_URL=[管理画面のProject URLの値]
NEXT_PUBLIC_SUPABASE_API_KEY=[管理画面のProject API keysの値]

セットアップ用のファイルを作る

src/utils/supabase.ts
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
);

今回作ったテーブルの型を定義する

src/types/types.ts
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;
};
yuyu

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;
yuyu

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;
yuyu

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;
yuyu

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;
yuyu

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が返される。

yuyu

Supabase の Auth を使う

仕様

  • トップページをログインページとする
  • トップページ以外でログインしていなければトップページにリダイレクトする
  • ログインした状態でトップページに来た場合はダッシュボードページにリダイレクトする
全ページにログイン状態に合わせてリダイレクトなどをする処理を入れる

import や一部の処理は省略しています

src/pages/_app.tsx
// データをフェッチしたりするための 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 を用意
src/hooks/useMutateAuth.ts
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 など一部、省略しています。

src/pages/index.tsx
/**
 * トップページ
 * ログイン、新規会員登録ページ
 */
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;

:::

yuyu

Next.js の 型定義について

型定義をどこに書くか問題が気になっていたが、講座が結構いい感じな気がするので今後もこの方法にしたい

こんな感じでアプリケーション全体で使うような型はまとめて定義して、各ページでしか使わない props などはそのファイル内に定義するのが良さそう

src/types/types.ts
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 は特定のオブジェクト型の型のプロパティを除外した型が作れるみたい
上の例では EditedTaskTask から created_atuser_id を除外したものになる

yuyu

Zustand を使って状態を管理する

更新中のタスクなどを管理するために Zustand (ザスタンド、チュースタンド)を使う

Zustand のセットアップ
src/stores/index.ts
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 に保存して扱っている

取得の処理

staleTimeInfinity にするとキャッシュはずっと有効になり、初回実行時以降勝手にデータを取りに行ったりしない。その代わりにキャッシュの更新はデータ更新時にフロント側でやる。

タスクは非同期で勝手に更新されたりしないデータなので、画面表示時など useQueryTasks が実行されたときにちゃんと新しいデータが取得されるだけで良いのでこうしている

お知らせなどユーザー以外がデータを更新した時にそれを更新したい時にはこれを設定すると良さそう

src/hooks/useQueryTasks.ts
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 のキャッシュを更新する

src/hooks/useMutateTask.ts
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部分

一部省略してます

src/pages/dashboard.tsx
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;
src/components/TaskForm.tsx
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>
  );
};
src/components/TaskItem.tsx
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>
  );
};
src/components/TaskList.tsx
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>
  );
};
yuyu

RLS(Row Level Security)を設定する

準備

  • 管理画面のテーブルのの右上から RLS is not enabled を選択
  • 有効にしたいテーブルの Enable RLSConfirm をクリック
    • 有効にしただけだと全てのアクセスができない状態になる
    • 取得も追加もできない

ポリシーを変更したいテーブルで New Policy をクリックして追加していく

  • Get started queckly → テンプレートから選べる
  • For full customization → 自分で一からポリシーを書く

ログインユーザーしか insert できない設定

  • Enable insert access for authenticated users only(追加に関する設定)を選択
    • 右下の Use this template を選択
    • INSERTTarget rolesauthenticated になっていて、WITH CHECK expressiontrue になっている状態で Review をクリック(ログインしているユーザーのみ追加ができるという設定)
    • Save policy をクリックして完了

同じ user_id のデータしか delete できない設定

  • Enabled delete access for users based on their user ID (削除に関する設定)を選択
    • 全てそのまま ReviewSave policy で同じ user ID のユーザーだけ削除できるように設定ができた

同じ user_id のデータしか update できない設定

  • 更新に関する設定をするが、これも Enabled delete access for users based on their user ID (削除に関する設定)を選択
    • Policy namedeleteupdate に書き換えて Enable update for users based on user_id とする
    • Allowed oparationUPDATE を選択する
    • USING expressionWITH CHECK expression を同じ記述(auth.uid() = user_id)にして、セーブする

同じ user_id のデータしか select できない設定

  • 選択に関する設定をするが、これも Enabled delete access for users based on their user ID (削除に関する設定)を選択
    • Policy namedeleteselect に書き換えて Enable select for users based on user_id とする
    • Allowed oparationSELECT を選択する
    • 中身はそのまま delete と同じにしてセーブする

本来は where 句とかで絞り込みをすると思うけど、それをポリシーで設定しておけば select * とした時も、このポリシーによって絞り込みがなされる感じ
ポリシーは where 句を追加するようなイメージなのかも

ログインしてれば全てのデータを select できる設定

上記とは別に、閲覧だけはさせたい時とかに使うやつっぽい

  • Enable insert access for authenticated users only(追加に関する設定)を選択
    • Policy nameinsertselect に変えて Enable select for authenticated users only とする
    • Allowed oparationSELECT を選択する
    • USING expression はそのまま true としておく

↑ログインしていないとデータが取得できないようにしたので、SSRなどサーバー側でデータを取得する際にはログイン情報が取得できず、データが空の配列になって見れなくなったはず

ユーザーの認証はクライアント側の JWTトークン を使っているので、こういうことが起きる
サーバー側で認証トークンを付与することもできるが、別途コードをセットする必要がある。

ログインしていなくてもだれでも見れるようにする

  • For full customization の方を選択
    • Policy nameEnable select for anonymous users とする
    • Allowed oparationSELECT を選択する
    • USING expressiontrue としておく
yuyu

JWT とは

https://techblog.yahoo.co.jp/advent-calendar-2017/jwt/

JSON Web Tokenの略称

発音は"ジョット"

ジェーダブリューティーって呼んでた...

JWTは2つのピリオド(".")で区切られた3つのパートによって成り立っている

この3つのパートにはそれぞれ役割があり、前から順番にヘッダー(Header)、ペイロード(Payload)、署名(Sinature)となっています。
<ヘッダー>.<ペイロード>.<署名>

yuyu

ON DELETE CASCADE を設定する

notes テーブルの id と comments テーブルの note_id をリレーションさせたい
そして notes のレコードが消えた時にそこに紐づくコメントも一緒に削除してほしい(ON DELETE CASCADE)のでその設定をする

  1. 特にリレーションは貼らずにそれぞれのテーブルを作る
  2. その設定画面では設定ができないので左サイドバーの SQL Editor から New query を選択する
  3. この画面では 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

yuyu

開発の流れについて

可能なら先に Supabase の設定を全て終わらせてから開発に進めると良さそう

  1. Authの設定
  2. Tableの構築(テーブルを作成する、リレーションを貼る、RLS を設定する)
  3. TypeScriptの型定義を作る(テーブル構造が明確なのでそれと同じ型定義をこの時点で作る)

データの取得とかもこれで挙動が変わるのでアプリ開発より先にやった方が良い。
また、テストとかを書く時とかにも、どのユーザーでどうなるのかとかを把握した状態で書けるから良さそう。

yuyu

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 周りの連携がしやすいのが書きやすさの違いなのかな?という気がした

yuyu

On-Demand ISR で更新される Note アプリを作る

On-Demand ISR とは

通常の ISR はキャッシュの有効期限を決めてアクセスがくるたびにキャッシュがなければ作り直す方式。
On-Demand ISR はこちらの任意のタイミングで再生成を行う方式

Next.js + Vercel 前提だけど通常の ISR だと getStaticPropsrevalidate にキャッシュの有効期限を入れることで実装できる。

今回は関連する情報が更新されるたびに再生成を行うようにする方法を学ぶ
イメージとしては、再生成をさせるための 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
tailwind.config.js
module.exports = {
  content: [
    "./src/pages/**/*.{js,ts,jsx,tsx}",
    "./src/components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

src/styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

最後に、不要なので src/styles/Home.module.css を削除

Prettier のセットアップ

個人的には以下の設定は最低限入れておきたいのでファイルを作って設定

.prettierrc
{
  "singleQuote": true,
  "semi": true
}

Supabase の設定

REVALIDATE_SECRET は ISR のために Supabase の Webhooks というところで使う
中身は任意のパスワードの乱数ならなんでもOK

NEXT_PUBLIC という接頭語で作った環境変数はクライアント側で使うもので、ビルド時に書き出される static のファイルに載ってしまうユーザーにも見えてしまう。

ユーザーに見せたくないものは上記の接頭語を使わずに定義すれば良いが、こうするとクライアントで実行した際の process.env から読み取れなくなるので注意。

getStaticPropsgetServerSidePropspages/api に配置した API などのサーバー側で動く時にはこちらも参照できる

.env.local
NEXT_PUBLIC_SUPABASE_URL=[管理画面のProject URLの値]
NEXT_PUBLIC_SUPABASE_API_KEY=[管理画面のProject API keysの値]
REVALIDATE_SECRET=[任意のパスワードを適当に]

https://www.luft.co.jp/cgi/randam.php

On-Demand ISR を実行するための API を立てる

pages/api に API を立てて、それを叩かれたら特定のページを再生しする処理が走るようにする
res.revalidate(ページのパス); とすることで、その対象のページが再生成される。

APIを立てる

以下は /notes ページを再生成するための API

src/pages/api/revalidate/index.ts
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

src/pages/api/[id].ts
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 に汎用的な関数を定義
src/utils/revalidation.ts
export const revalidateList = () => {
  fetch('/api/revalidate');
};

export const revalidateSingle = (id: string) => {
  fetch(`/api/revalidate/${id}`);
};

useMutaion を作ってデータを更新する

データの追加、更新、削除に成功したタイミングで revalidateListrevalidateSingle を叩いている。
関連するページだけ ISR されれば良いので、そこは更新したデータがどのページに影響があるかを考えて追加する
これでデータが更新されると裏で再生性が走るので、画面を更新したり、再度訪れた時に表示が更新されている

データを更新しながら ISR を実行する処理を叩く
src/hooks/useMutateNote.ts
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 を実行する

こんな感じで updateNoteMutationcreateNoteMutation を叩く

実際に更新するページ側

一部、省略してます

src/components/NoteForm.tsx
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 は叩かないようにした。

src/pages/api/revalidate/index.ts
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 hookTablenotes を選択
    • どのテーブルに変化があったら動くかの設定
  • EventsInsert, Update, Delete の全てを選択
    • 対象のテーブルにどんな変化があれば動くかの設定
  • Type of hookHTTP Request を選択
  • HTTP Request
    • MethodsPOST にする
    • URL をデプロイしたURLのAPIに向ける( https://~~~~/api/revalidate
  • HTTP Paramssecret と言う名前で REVALIDATE_SECRET に設定した乱数を設定

これで、notes テーブルに追加、更新、削除がおきる度に Webhooks で API が叩かれる
設定した secret の値が一致するはずなので、処理は実行される
それ以外の方法で叩いたとしても secret がなければ起動しないので勝手に ISR が叩かれることはない