📗

【Supabase入門】認証・DB・リアルタイムリスナーを使ってチャットアプリを作ろう

2023/04/13に公開

こんにちわ!フロントエンドエンジニアのわでぃんです。

最近、BaaSの中でも特に勢いのある、Supabaseについての入門記事です。
簡単なチャットアプリを開発しながら使い方や、一連の流れを覚えていきましょう!

作成するアプリの概要として、Supabaseが提供してる認証機能や、リアルタイムリスナーを活用し、ユーザー同士がリアルタイムでやりとりできるチャットアプリを作ります。
Next.js・TypeScriptで開発します。

Supabaseはここ2.3年くらい?で人気がどんどん高くなってきていますが、情報が錯綜している感(特に日本語の情報)があったため、1からSupabaseを学んで開発したい人や、Supabase興味あるけど手が出せていないという人向けの入門記事を書いてみました!

Supabaseとは

Supabaseは、オープンソースのFirebase代替として注目を集めているBaaSです。
PostgreSQLデータベースをベースに、リアルタイム更新、認証、ストレージ、サーバーレス関数など、フルスタック開発に必要な機能が提供されています。

Supabaseはオープンソースのプロジェクトで、コミュニティによって開発とサポートが行われています。
公式Discordも、とても盛り上がっています。日本語のチャンネルもあります!
タイラーさんが日本向けのサポートもよくしているみたいで、何かあると聞きやすい環境なのはありがたいですね。

(あと、読み方はよくスパベースと言われますが、実はスーパベースらしいです...)

学習を始めた経緯

Supabaseを学習した経緯としては、SupabaseがPostgreSQLデータベースを採用している事が大きかったです。

  • Cloud FirestoreはNoSQLなので、RDBを触りたい
  • とりあえずGUIでテーブル作成して、徐々にPostgreSQLを書きたい
  • 主戦場がNext.jsなので認証・リアルタイムリスナー・ストレージなど1つのサービスでサクッと任せたい
    などの理由からSupabaseのキャッチアップを行いました。

RDBに慣れている方も、これからDB周り学習したい方もSupabaseはおすすめできます!

料金体系

*2023.04現在

Free Pro Enterprise
$0/月 $25/月 要相談
500MBまでのDB 8GBまでのDB 要相談
1GBまでのストレージ 100GBまでのストレージ 要相談

FirebaseやAWSでは従量課金制ですが、Supabaseではプランが決まっているのがいいですね。
フリープランでもある程度使えるので、個人開発で気軽に触ることができます!

作成するもの

エンジニア向けのチャットアプリを作成します。
GitHubでサインインをして、オープンチャットでやり取りできるようにします。

作成したアプリ↓

GitHubソースコードはこちら↓
https://github.com/wadeen/supabase-chat-app

今回、この記事ではSupabaseを使って実装する上で必要な箇所のみ解説しているので、スタイリングや基本的なReactの基本的な構文については省略しています。
分かりにくいところがあれば、GitHubのソースコードを見ていただければと思います🙏

この記事のディレクトリ構成↓

root/
├ components/
│     ├  ChatApp.tsx
│     ├  Layout.tsx
│     ├  LogoutButton.tsx
│     └  SignInGithub.tsx
├ hooks/
│  └ useAuth.ts
├ pages/
│  └ index.tsx
├ lib/
│ ├ supabase.ts
│ └ supabaseFunctions.ts
├ .env.local

この記事内で作成するもの(スタイリングなし)↓

環境構築

コードを書いていく前に、Supabase側でプロジェクトの作成を行います。

プロジェクトの作成

まだSupabaseにサインアップしていない方は、https://app.supabase.io にアクセスしてサインアップしましょう。GitHubで連携がおすすめです!

サインインできたら、プロジェクトの作成をします。
Organizationsは、アカウント作成時に自動的に作成されると思うのでそれを使いましょう。
左メニューの All projects > New project で新しいプロジェクトが作成できます。
Choose organizationでは、上記の自動生成されたorganization(xxx'org)をクリックします。

*New organization ではないので注意
*Freeプランでは、プロジェクトは2つまでしか作成できません

今回は、chat-appというプロジェクトを作成しました。

Supabase側でプロジェクトを作成するまでに少し時間がかかるので、環境構築を進めます。

今回は、Next.js・TypeScriptで開発を行います。
supabase-chat-appというリポジトリを作成して作業します。

// Next.jsのインストール
npx create-next-app supabase-chat-app --typescript

次に、Supabaseのパッケージをインストールします。

// npm
npm install @supabase/supabase-js

// yarn
yarn add @supabase/supabase-js

インストールが完了したら環境変数を定義していきます。

.env.local
NEXT_PUBLIC_SUPABASE_URL=your_supabase_api_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key

URLとアクセスキーが必要ですので、先ほど作成したSupabaseのプロジェクトの管理画面から取得します。
NEXT_PUBLIC_SUPABASE_URL => Settings > API > Project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY => Settings >API >Project API keys [anon public]

次に、Supabaseクライアントの初期化をします。
libフォルダの中にsupabase.tsを作成します。

supabase.ts
import { createClient } from "@supabase/supabase-js";

export type Database = {};

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);

export default supabase;

まだデータベースを作成してないないため、空オブジェクトを渡します。
あとで、データベース作成時に型付けしましょう!

ひとまずこれで基本的な事前準備・環境構築が完了しました!

認証周りの実装

次に、認証周りの実装に入ります。
Supabaseでは多くのプロバイダーでユーザー認証することが可能です。

ただ、各プロバイダーごとに設定が必要なため、詳細はドキュメントをご確認ください。
プロバイダーの利用例として、今回はGitHubをご紹介します。

認証周りのカスタムフックの作成

認証周りの処理は、同じような処理が多いので、カスタムフックにまとめちゃいましょう!
Supabaseでは、ログインの状態を監視する便利なonAuthStateChange()という関数があり、リアルタイムで認証状態を検知してくれます。。

onAuthStateChange()は、コールバック関数に、(event, session)を受け取ります。
eventはSIGNED_INSIGNED_OUTなどの認証の状態を表し、
sessionは、ユーザーのidや最終サインイン状況、またGitHubでログインしている場合は、登録しているメールアドレス・ニックネーム・アバターURLなどの情報が入っています。

今回は、sessionのデータのみが欲しかったので以下のようにステート管理にしました。
また、必ずリスナーの解除も行いましょう。

useAuth.ts
  const [session, setSession] = useState<Session | null>(null); // ログイン状態を管理

  useEffect(() => {
    // ログイン状態の変化を監視
    const { data: authData } = supabase.auth.onAuthStateChange((_, session) => {
      setSession(session);
    });

    // リスナーの解除
    return () => authData.subscription.unsubscribe();
  }, []);

GitHubのログインについても実装していきます。GitHubでのログインは、GitHubプロバイダの登録が必要ですので、後述します。先に、コードを完成させましょう!

useAuth.ts
  // GitHubでサインイン
  const signInWithGithub = async () => {
    try {
      const { error } = await supabase.auth.signInWithOAuth({ provider: "github" });
      if (error) {
        setError(error.message);
      }
    } catch (error) {
      if (error instanceof Error) {
        setError(error.message);
      } else if (typeof error === "string") {
        setError(error);
      } else {
        console.error("GitHubとの連携に失敗しました。");
      }
    }
  };

  // ログインユーザーのプロフィール取得: GitHub
  const profileFromGithub: {
    nickName: string;
    avatarUrl: string;
  } = {
    nickName: session?.user?.user_metadata.user_name,
    avatarUrl: session?.user?.user_metadata.avatar_url,
  };

各プロバイダでログインする際には、 以下のようにsignInWithOAuth()を使います。

supabase.auth.signInWithOAuth({ provider:<プロバイダ名> });

ちなみに、今回は実装しないですがメールアドレスの場合は、以下のようになります。

// サインアップ
supabase.auth.signUp({ email, password });
// サインイン
supabase.auth.signInWithPassword({ email, password });

nickNameavatarUrlについては、後ほどメッセージ送信時に使用するので先に定義しておきます。

最後に、サインアウトです。シンプルに、signOut()でOKです。
サインアウトは、プロバイダ認証・メールアドレス関わらず全て以下の書き方でOKです。

useAuth.ts
  // サインアウト
  const signOut = async () => {
    await supabase.auth.signOut();
  };
useAuth.tsの全ファイル
useAuth.ts
import supabase from "@/lib/supabase";
import { Session } from "@supabase/supabase-js";
import { useEffect, useState } from "react";

const useAuth = () => {
  const [session, setSession] = useState<Session | null>(null); // ログイン状態を管理
  const [error, setError] = useState(""); // エラー状況を管理

  useEffect(() => {
    // ログイン状態の変化を監視
    const { data: authData } = supabase.auth.onAuthStateChange((_, session) => {
      setSession(session);
    });

    // リスナーの解除
    return () => authData.subscription.unsubscribe();
  }, []);

  // GitHubでサインイン
  const signInWithGithub = async () => {
    try {
      const { error } = await supabase.auth.signInWithOAuth({ provider: "github" });
      if (error) {
        setError(error.message);
      }
    } catch (error) {
      if (error instanceof Error) {
        setError(error.message);
      } else if (typeof error === "string") {
        setError(error);
      } else {
        console.error("GitHubとの連携に失敗しました。");
      }
    }
  };

  // ログインユーザーのプロフィール取得: GitHub
  const profileFromGithub: {
    nickName: string;
    avatarUrl: string;
  } = {
    nickName: session?.user?.user_metadata.user_name,
    avatarUrl: session?.user?.user_metadata.avatar_url,
  };

  // サインアウト
  const signOut = async () => {
    await supabase.auth.signOut();
  };

  return {
    session,
    error,
    profileFromGithub,
    signInWithGithub,
    signOut,
  };
};

export default useAuth;

これで認証の基本実装はOKです!
次に、GitHubプロバイダが使えるように設定していきましょう。

GitHubのプロバイダ認証

GitHubのプロバイダ認証をする際は、自分のGitHubでアプリケーションを作成する必要があります。
アプリケーションを作成すると、クライアントIDとシークレットキーの作成ができます。

こちらから登録してください。

Homepage URLは、今回はlocalhostを(仮)で入れましたが問題なく動きました。
URLが決まっている場合は、ここで入力してください。

Authorization callback URLは環境変数に設定したURLの末尾に/auth/v1/callbackを追加してください。完了したら、Register applicationを押します。
そうすると、Client IDと、Client secretsが生成されるので、SupabaseのAuthentication > Providers > GitHubの中にIDとキーを打てばOKです。

GitHubにEnaledの表記になっていれば連携成功です。

リダイレクトURLも設定しておきます。デプロイ時は変更してください。

*ちなみにGoogleであればGCPコンソール画面での設定、Twitterであれば開発者プラットフォームで設定が必要になります。
具体的なプロパイダーの手順については、こちらのドキュメントを参照してください。

サインイン・サインアウトボタン

今回は、GitHubのみでサインインするので、クライアントサイドでのデータの受け渡しがなく、ボタンをポチるだけの簡単仕様です!

componentsフォルダに、SignInGithub.tsxLogoutButton.tsxを作成します。

SignInGithub.tsx
import useAuth from "@/hooks/useAuth";

const SignInGithub = () => {
  const { signInWithGithub, error } = useAuth();

  return (
    <div>
      <button onClick={signInWithGithub}>Githubでサインインする</button>
      {error && <p>{error}</p>}
    </div>
  );
};

export default SignInGithub;
LogoutButton.tsx
import useAuth from "@/hooks/useAuth";

const LogoutButton = () => {
  const { signOut } = useAuth();
  return <button onClick={signOut}>ログアウト</button>;
};

export default LogoutButton;

どちらも、先ほど作成したカスタムフックの処理を使っているのでかなりスマートな記述になります。

 {error && <p>{error}</p>}

errorも、カスタムフックでステート管理していたので表示させます。
GitHub認証なのでここでエラーが出ることは少ないですが、メールアドレスでのサインアップ(イン)の時は、「パスワードが6文字未満だよ」、「パスワード間違ってるよ」などど警告してくれます。

ログアウトボタンは、ログイン時のみ表示されるように、Layout.tsを作成してヘッダーに加えておきましょう!

Layout.tsの全ファイル
Layout.ts
import LogoutButton from "./LogoutButton";
import useAuth from "@/hooks/useAuth";

type Props = {
  children: React.ReactNode;
};

const Layout = ({ children }: Props) => {
  const { session: isLogin } = useAuth();

  return (
    <>
      <header>
        {isLogin && <LogoutButton />}
        <p>This is the header area</p>
        <hr />
      </header>
      <main>{children}</main>
      <footer>
        <hr />
        <p>This is the footer area</p>
      </footer>
    </>
  );
};

export default Layout;

index.tsxSignInGithubコンポーネントを呼び出して、サインインボタンをクリックすると、ログインできていると思います。まだログイン後の処理を書いてないため、コンソールなどでご確認ください。

チャットアプリの実装

いよいよ、メインの実装に入ります。まずは、データベースの作成を行います。

Supabaseのプロジェクトに入り、Databaseを開きます。
「New table」を選択して、今回は以下のように作成します。

idは、uuidを選択し、Default Valueに、uuid_generate_v4()を入れているので自動的に、ユニークなIDが入ります。
createdAtは、タイムスタンプで自動的に現在時刻が入ります。
Default Valueに (now() at time zone 'Asia/Tokyo') を追加すると日本時間で表示してくれます。

messageは、texttypeを選択すると文字列として管理できます。
nickNameavatarUrlは、先ほどカスタムフックで定義したGitHubに登録しているデータをtexttypeで格納します。

今回はリアルタイム更新を使うため、Enable Realtimeにもチェックを入れてください。
あとは、Enable Row Level Security (RLS)にチェックを入れます。

キャッチアップのために、Supabaseをちょっと触ってみるだけでしたらRLSを無効化して、慣れてきてからポリシーの登録(RLSの有効化)をしてもいいかもしれません。。(デフォルトで有効化になってて、個人的に気づくの時間かかりました😅)

ひとまず、ログインしているユーザーのみ読み書きできるようにしていきます。

*一般公開するアプリの場合は、USING expressionなどで必要な権限周りをしっかり設定してください。
これで、とても簡単にデータベースの作成が終わりました🚀

先ほど、supabase.tsの型を空にしていたので、型を指定してあげましょう!

supabase.ts
export type Database = {
+ id: string;
+ createdAt: string;
+ message: string;
+ nickName: string;
+ avatarUrl: string;
};

リアルタイムリスナー

次に、リアルタイムリスナーを張っていきます。
今回は、記事の取得・データの追加のみ行えるようにします。
削除・編集機能などは持たせません。ただ、追加することができれば、実装方法は似ているので追加機能が欲しい方はチャレンジしてみてください!

まずは、データの追加や取得などの必要な関数を書いていきます。
libフォルダに、supabaseFunctions.tsを作成します。

supabaseFunctions.ts
import supabase, { Database } from "./supabase";

// テーブル名
export const TABLE_NAME = "chat-app";

// データの全取得
export const fetchDatabase = async () => {
  try {
    const { data } = await supabase.from(TABLE_NAME).select("*").order("createdAt");
    return data;
  } catch (error) {
    console.error(error);
  }
};

type InsertProps = Pick<Database, "message" | "nickName" | "avatarUrl">;

// データの追加
export const addSupabaseData = async ({ message, avatarUrl, nickName }: InsertProps) => {
  try {
    await supabase.from(TABLE_NAME).insert({ message, avatarUrl, nickName });
  } catch (error) {
    console.error(error);
  }
};

fetchDatabaseで、マウント時にデータを全取得して表示させます。
毎回全件取得するとAPIの通信容量が大きくなってしまうため、最初のみ全件取得してローカルで差分を変更して管理します。

クライアントサイドでのテーブルの操作をする場合は、テーブルを選択してチェーンメソッドで書いていくのが基本になります。

supabase.from(TABLE_NAME).select("*").order("createdAt")

データベースにデータを追加する場合は、insert()を使用します。
先ほど作成したデータベースのうち、idとcreatedAtは自動生成のためそれ以外の必要データを追加します。

supabase.from(TABLE_NAME).insert({ message, avatarUrl, nickName });

また、データベースの特定の値を編集・削除する場合などは、eq()match()などを用いて、対象のレコードを指定して操作を行います。
この辺りに何のメソッドを使えばいいのかは、公式ドキュメントで細かく解説してくれているので参考にしてみてください🙆

次に、ChatApp.tsxを作成します。
リアルタイムデータ更新の処理を書いてきます。

ChatApp.tsx
  // リアルタイムデータ更新
  const fetchRealtimeData = () => {
    try {
      supabase
        .channel("table_postgres_changes") // 任意のチャンネル名
        .on(
          "postgres_changes", // ここは固定
          {
            event: "*", // "INSERT" | "DELETE" | "UPDATE"  条件指定が可能
            schema: "public",
            table: TABLE_NAME, // DBのテーブル名
          },
          (payload) => {
            // データ登録
            if (payload.eventType === "INSERT") {
              const { createdAt, id, message, avatarUrl, nickName } = payload.new;
              setMessageText((messageText) => [...messageText, { createdAt, id, message, avatarUrl, nickName }]);
            }
          }
        )
        .subscribe();

      // リスナーの解除
      return () => supabase.channel("table_postgres_changes").unsubscribe();
    } catch (error) {
      console.error(error);
    }
  };

いくつか解説します。
リアルタイムリスナーを張っているので、DBに追加・削除・更新などがあると、コールバック関数が走ります。
コールバック関数からイベントタイプごとに処理を分けており、今回は追加のみなのでINSERT=追加の時に、ローカルのステートも追加するようになっています。
削除や更新した場合の処理が書きたい場合は、DELETEUPDATEを指定して、処理を書いてください。

イメージしやすいように、追加("INSERT")した場合のログを表示させます。

INSERTの場合はnewに、DELETEの場合はoldに差分が入るので合わせてステートを更新してあげます。

またuseEffectマウント時のみ全データの取得と、リアルタイムリスナーを張ります。

ChatApp.tsx
  // 初回のみ全データフェッチとリアルタイムリスナー登録
  useEffect(() => {
    (async () => {
      const allMessage = await fetchDatabase();
      setMessageText(allMessage as Database[]); // '{ [x: string]: any; }[] | null'
    })();
    fetchRealtimeData();
  }, []);

あとは、メッセージ送信時に、メッセージとGitHubのニックネーム&アバターURLをデータベースに格納します。

 addSupabaseData({ message: inputText, ...profileFromGithub }); // DBに追加
ChatApp.tsxの全ファイル
ChatApp.tsx
import { Database } from "@/lib/supabase";
import { TABLE_NAME, addSupabaseData, fetchDatabase } from "@/lib/supabaseFunctions";
import { useEffect, useState } from "react";
import supabase from "@/lib/supabase";
import Image from "next/image";
import useAuth from "@/hooks/useAuth";
import { useRouter } from "next/router";

const ChatApp = () => {
  const [inputText, setInputText] = useState(""); // 入力テキスト
  const [messageText, setMessageText] = useState<Database[]>([]); // メッセージ
  const { session: isLogin, profileFromGithub } = useAuth();
  const router = useRouter();

  // ログアウト済みの場合はログインページにリダイレクト
  if (!isLogin) router.push("/");

  // リアルタイムデータ更新
  const fetchRealtimeData = () => {
    try {
      supabase
        .channel("table_postgres_changes") // 任意のチャンネル名
        .on(
          "postgres_changes", // ここは固定
          {
            event: "*", // "INSERT" | "DELETE" | "UPDATE"  条件指定が可能
            schema: "public",
            table: TABLE_NAME, // DBのテーブル名
          },
          (payload) => {
            // データ登録
            if (payload.eventType === "INSERT") {
              console.log('payload: ', payload)
              const { createdAt, id, message, avatarUrl, nickName } = payload.new;
              setMessageText((messageText) => [...messageText, { createdAt, id, message, avatarUrl, nickName }]);
            }
          }
        )
        .subscribe();

      // リスナーの解除
      return () => supabase.channel("table_postgres_changes").unsubscribe();
    } catch (error) {
      console.error(error);
    }
  };

  // 初回のみ全データフェッチとリアルタイムリスナー登録
  useEffect(() => {
    (async () => {
      const allMessage = await fetchDatabase();
      setMessageText(allMessage as Database[]); // '{ [x: string]: any; }[] | null'
    })();
    fetchRealtimeData();
  }, []);

  // 入力したメッセージ
  const onChangeInputText = (event: React.ChangeEvent<HTMLInputElement>) => setInputText(() => event.target.value);

  // メッセージの送信
  const onSubmitNewMessage = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (inputText === "") return;
    addSupabaseData({ message: inputText, ...profileFromGithub }); // DBに追加
    setInputText("");
  };

  return (
    <div>
      {messageText.map((item) => (
        <div key={item.id} data-user-id={item.nickName}>
          <div>
            <a href={`https://github.com/${item.nickName}`} target="_blank" rel="noopener noreferrer">
              {/* eslint-disable-next-line @next/next/no-img-element */}
              {item.avatarUrl ? <img src={item.avatarUrl} alt="アイコン" width={80} height={80} /> : <Image src="/noimage.png" alt="no image" width={80} height={80} />}
              <p>{item.nickName ? item.nickName : "名無し"}</p>
            </a>
            <p>{item.createdAt}</p>
          </div>
          <p>{item.message}</p>
        </div>
      ))}

      <form onSubmit={onSubmitNewMessage}>
        <input type="text" name="message" value={inputText} onChange={onChangeInputText} aria-label="新規メッセージを入力" />
        <button type="submit" disabled={inputText === ""}>
          送信
        </button>
      </form>
    </div>
  );
};

export default ChatApp;

最後に、index.tsxでログイン状態で分岐させてあげましょう。

index.tsx
import Layout from "@/components/Layout";
import ChatApp from "@/components/ChatApp";
import useAuth from "@/hooks/useAuth";
import SignInGithub from "@/components/SignInGithub";

const Home = () => {
  const { session: isLogin } = useAuth();

  // ログインしている場合のみチャットページを表示
  return isLogin ? (
    <Layout>
      <h2>チャットアプリ</h2>
      <ChatApp />
    </Layout>
  ) : (
    <Layout>
      <h2>Githubでサインイン</h2>
      <SignInGithub />
    </Layout>
  );
};

export default Home;

試しに、GitHubでログインしてメッセージを送信してみましょう!

無事にログインができてリロードしなくても、リアルタイムで反映されました🥳

データベースも見てみましょう。
少し見づらいですが、 しっかり格納されていることがわかります!

必要な機能は揃ったので、あとはスタイリング・日付のフォーマットなどをしたら完成です!
完成したものは以下になります。

おわり

今回のブログでは、Supabaseを使用してチャットアプリの機能を実装する方法をご紹介しました。
認証やリアルタイムデータベースは直感的なので、個人的にはFirebaseよりも使いやすいと感じました。

Supabaseは今もどんどん機能追加しており、とても魅力的なサービスなので今後の動向にも注目しましょう!

参考

https://zenn.dev/hrtk/articles/3da84e46c97267
https://note.com/shift_tech/n/n5191b5f19c9b

Discussion