😸

Supabase + Nuxt 3でチャットアプリを作ってみた

2023/10/17に公開

Supabase + Nuxt 3でチャットアプリを作ってみたの作成手順やコードについてまとめました。
Qiitaには以前投稿していたのですが、zennでは初投稿です。

https://youtu.be/Q0yCTRuGWYQ

Supabase で何か作るという目的でチャットアプリをつくってみました。
この動画は、デモ動画です。

https://zenn.dev/chot/articles/ddd2844ad3ae61

こちらの記事を参考にさせていただきました。
同じフレームワークでも良かったのですが、他のフレームワークではどのように構築するのか考察したかったので Nuxt 3 で作ってみました。

実装中のスクラップメモしていた内容を記事化しました。

https://zenn.dev/redamoon/scraps/89180b11c07be2

Supabase とは

オープンソースのFirebase代替品として作られたクラウドベースのプラットフォームです。
開発者が作るWebアプリケーションに必要なツールやサービスを提供します。

Supabaseで出来ること

  • オープンソースのリアルタイムデータベース
  • アプリケーションに必要なバックエンドの機能を提供するプラットフォーム
  • PostgreSQLをベースとしたリアルタイムデータベースを提供
  • データの変更をリアルタイムで購読することができる
  • PostgreSQLの機能やエコシステムを活かせるため拡張性が高い
  • ユーザサインアップ・ログイン・認証トークン管理などの一般的な認証機能をサポート
  • ファイルアップロード・ダウンロード・管理といったストレージ機能を提供
  • フロントエンドの疎通にはRESTful API や GraphQLを提供するため、独自のAPI構築が不要
  • リアルタイムで購読し、フロントエンドへの反映が可能
  • オープンソースのためGitHubでコードを確認することができる
  • 完全オープンソースのため、Firebaseよりもカスタマイズ・拡張性が容易

作ったもの

  • Supabase と Nuxt 3 を利用したWebチャットアプリ

リポジトリ

作成したリポジトリは以下になります。
スタイルは、Tailwindを利用しています。

https://github.com/redamoon/supabase-nuxt3-chat

環境

  • nuxt 3.7.1
  • Node.js v18.17.0
  • npm 9.8.1

チャットアプリの構成

  • ログイン(GitHub)
  • 投稿・編集・削除機能

Supabaseの設定

マネージドホスティングサービスの Supabase を利用します。
アカウントを作成して、プロジェクトとDBを作成します。

マネージドホスティングサービスは、フリープランで 2プロジェクト まで作成可能です。

Supabaseプロジェクトの作成

https://supabase.com/dashboard/projects

  • New project からプロジェクトを作成
  • Project name にプロジェクト名を入力 chat-app という名前で作成
  • Regionは、Asia tokyoを選択

DBの作成

チャットのメッセージを保存するテーブルを作成します。
Supabaseの管理画面から SQL Editor が提供されているので以下のSQLを実行します。

CREATE TABLE messages (
    createdAt TIMESTAMPTZ DEFAULT now() NOT NULL,
    message TEXT NOT NULL,
    nickName TEXT,
    avatarUrl TEXT,
    id UUID DEFAULT uuid_generate_v4() PRIMARY KEY
);

nickNameとavatarUrlは、GitHubのログイン情報から取得するためのカラムです。

Name Type Default Value Primary
id uuid uuid_generate_v4()
createdAt timestamp now()
messaage text
nickName text
avatarUrl text

作成すると以下のテーブルが作成されます。
messages というテーブルをチャットで利用するDBになります。

Nuxt / Supabaseのパッケージをインストール

nuxt3をインストールして、環境を用意します。

npx nuxi@latest init supabase-nuxt3-chat

公式が提供しているクライアントをインストールします。

https://supabase.io/docs/reference/javascript/supabase-client

インストールすることで、Nuxtアプリ側からSupabaseを操作することができます。

npm install @supabase/supabase-js

環境変数を設定

Supabaseの接続URLとAPI Keyを環境変数に設定します。
リポジトリのルート配下に .env を用意し以下を記述します。

https://supabase.com/dashboard/project/{ReferenceID}/settings/api 自身の該当プロジェクトのプロジェクトにアクセスして、APIとURLを確認してください。

NUXT_PUBLIC_SUPABASE_URL=https://{Reference ID}.supabase.co
NUXT_PUBLIC_SUPABASE_KEY={APON_KEY}

次に nuxt.config.ts に環境変数を設定します。
runtimeConfigに設定することで、クライアント側からも参照できるようになります。

export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      SUPABASE_URL: process.env.SUPABASE_URL,
      SUPABASE_KEY: process.env.SUPABASE_KEY,
    },
  }
})

エイリアスの設定

コンポーネントへのパスを短くするため、エイリアスを設定します。

import path from "path";
export default defineNuxtConfig({
  vite: {
    resolve: {
      alias: {
        "@": path.resolve(__dirname, "./"),
      },
    },
  },
})

Supabase CLIのインストール

DBから取得データの型を利用するために、Supabase CLI をインストールします。

CLIを利用するためには、事前にAccessTokenを発行しておきます。

発行されたTokenをコピーし CLIで型をする際に利用します。

npm i supabase

Supabase login

Supabase login にログインします。

npx supabase login  // Enter -> 生成したアクセストークンを入力する

Supabase init

Supabase init でプロジェクトを初期化します。

supabase init
 -> Generate VS Code workspace settings? [y/N] N (VS Code のワークスペースファイルを生成したい場合は Y
 -> Open the supabase-nuxt3-chat.code-workspace file in VS Code.
 -> Finished supabase init.

supabase link でプロジェクトを紐付けます。
実行時にDBのパスワードが求められる。
Reference ID は 作成したプロジェクトのIDです。

supabase link --project-ref <Reference ID>

Supabase generate

supabase generate でDBから型を生成します。
--linked をつけることで、DBの変更を検知して型を更新することができます。
スキーマの情報を types/ に出力します。

supabase gen types typescript --linked > ./types/schema.ts

割愛しますが、他にも型の変更を検知してGitHub Actionsで自動化することも可能です。

実装

環境の準備が整ったら、次に実装にすすめていきます。

plugins/supabase.tsを作成

supabaseのDBへのアクセスは常に行うため、pluginにしておきます。
クライアントのメソッド利用し createClient でDBに接続します。
runtimeConfigから環境変数を取得し createClient に渡します。

Databaseは、先程生成した型ファイルをcreateClientに渡します。

import { createClient } from "@supabase/supabase-js";
import type { Database } from "@/types/schema";

export default defineNuxtPlugin((nuxtApp) => {
  const runtimeConfig = nuxtApp.$config;
  const supabase = createClient<Database>(
    runtimeConfig.public.SUPABASE_URL as string,
    runtimeConfig.public.SUPABASE_KEY as string,
  );
  nuxtApp.$supabase = supabase;
});

作成したpluginを nuxt.config.ts を設定します。

export default defineNuxtConfig({
  plugins: [
    '~/plugins/supabase.ts'
  ],
})

dateToStringの実装

DBから取得した日付を文字列に変換する関数を定義します。
このコードは参考記事のものを利用しています。

投稿時間を表示させるため、 daysjs をインストールします。

npm install dayjs --save

utils/dateToString.ts を作成します。

import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";

dayjs.extend(utc);
dayjs.extend(timezone);

export const dateToString = (date: string | Date, format: string) => {
  return dayjs.utc(date).tz("Asia/Tokyo").format(format);
};

useDatabaseの実装

SupabaseのDBへの接続やその他の処理をカスタムフックで定義していきます。
ページ側で実装するとコードが長くなるため、カスタムフックを作成することで共通化しておきます。

composable/useDatabase.ts を作成します。

ここでの処理は、メッセージログの取得・追加・削除・編集の処理を定義します。

  1. インポート、必要なライブラリをインストール
  2. カスタムフックの定義: useSupabase
  3. 変数の初期化
    1. nuxtApp:Nuxtアプリケーションのインスタンスを取得
    2. data:データベースから取得したメッセージデータを保持するためのリアクティブな変数
    3. TABLE_NAMEは、supabsaeで作成したテーブル名
  4. fetchDatabases でデータベースからのデータの取得
    1. ログイン後過去のチャットのやり取りを取得するための関数(ログイン後、データセットされる)※本来であれば、全データ取得せずlimitを利用しスクロールに応じてデータを再取得するが今回は未対
  5. deletedSupabaseData でメッセージの削除
    1. 自分自身が投稿したメッセージを削除する機能
  6. editedSupabaseData でメッセージの編集
    1. 自分自身の投稿メッセージを編集する機能
  7. addSupabaseData でメッセージを追加
    1. input textで入力送信で、メッセージを投稿する機能
  8. 戻り値
    1. カスタムフックから返す値を定義
    2. データやデータベースの操作する関数を提供
import { ref, onMounted } from "vue";
import { Database } from "@/types/schema";
import { SupabaseClient } from "@supabase/supabase-js";

export default function useSupabase() {
  const nuxtApp = useNuxtApp();
  const supabase = nuxtApp.$supabase as SupabaseClient;

  const data = ref<Database["public"]["Tables"]["messages"]["Row"][] | null>(
    null,
  );

  const TABLE_NAME = "messages";

  const fetchDatabase = async () => {
    try {
      const { data: fetchedData } = await supabase
        .from(TABLE_NAME) // messagesテーブルを指定
        .select("*") // 全てのカラムを取得
        .order("createdAt"); // createdAtカラムでソート
      data.value = fetchedData;
    } catch (error) {
      console.error(error);
    }
  };

  onMounted(fetchDatabase); // Mounted時にデータをフェッチ

  const deletedSupabaseData = async (id: string) => {
    try {
      await supabase.from(TABLE_NAME).delete().match({ id: id }); // 指定したIDの行を削除
    } catch (error) {
      console.error(error);
    }
  };
  const editedSupabaseData = async (id: string, text: string) => {
    try {
      await supabase.from(TABLE_NAME).update({ message: text }).match({ id }); // 指定したIDの行を更新
    } catch (error) {
      console.error(error);
    }
  };
  const addSupabaseData = async ({
    message,
    avatarUrl,
    nickName,
  }: Pick<
    Database["public"]["Tables"]["messages"]["Row"],
    "message" | "nickName" | "avatarUrl"
  >) => {
    try {
      await supabase.from(TABLE_NAME).insert({ message, avatarUrl, nickName });
    } catch (error) {
      console.error(error);
    }
  };

  return {
    TABLE_NAME,
    data,
    fetchDatabase,
    addSupabaseData,
    deletedSupabaseData,
    editedSupabaseData,
  };
}

ログイン認証の実装

Supabaseには、Auth Providers が用意されており、今回は簡易的にGitHubを利用して認証機能の実装した。
https://supabase.com/dashboard/project/{ReferenceID}/auth/providers にアクセスすることで、認証プロバイダの設定ができます。

ブロバイダの設定は、以下を参考に設定してください。

https://zenn.dev/chot/articles/ddd2844ad3ae61#githubのプロバイダ認証

ログイン認証も同様にコードが長くなるため、カスタムフックとして定義しました。

  • サインイン
  • サインアウト
  • 認証状態の監視
  • プロフィール情報の取得

composable/useAuth.ts を作成します。

ここでの処理は、ログイン・ログアウト・プロフィールの取得を定義します。

  1. インポート、必要なライブラリをインストール
  2. カスタムフックの定義: useAuth
  3. 変数の初期化
    1. nuxtApp:Nuxtアプリケーションのインスタンスを取得
    2. sessionとerror:ユーザのセッション情報とエラーメッセージをリアクティブな変数として定義
  4. 認証状態の監視:onMountedフック内で、Supabaseの認証状態変更されたときにセッション情報を更新する設定
  5. GitHubでのサインイン
    1. signInWithGitHub 関数は、ユーザをGitHubのOAuthを使用してサインインする関数
  6. GitHubプロフィール情報を取得
    1. profileFromGitHub は、GitHubのユーザ名とアバターURLを取得するための関数
  7. サインアウト
    1. signOut関数を定義して、ユーザのサインアウトさせる関数
  8. 戻り値
    1. カスタムフックから返す値を定義
  9. エクスポートは、呼び出し側で利用するための設定
import { Session, SupabaseClient } from "@supabase/supabase-js";
import { onMounted, ref, computed } from "vue";

const useAuth = () => {
  const nuxtApp = useNuxtApp();
  const supabase = nuxtApp.$supabase as SupabaseClient;
  const session = ref<Session | null>(null);
  const error = ref<string>("");

  onMounted(() => {
    const { data: authData } = supabase.auth.onAuthStateChange(
      (_, newSession) => {
        session.value = newSession;
      },
    );
    return () => {
      authData.subscription.unsubscribe();
    };
  });

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

  const profileFromGithub = computed(() => {
    return {
      nickName: session.value?.user?.user_metadata?.user_name || "",
      avatarUrl: session.value?.user?.user_metadata?.avatar_url || "",
    };
  });

  const signOut = async () => {
    await supabase.auth.signOut();
  };

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

export default useAuth;

SignInGithubの実装

components/Feature/SignInGithub.vue で、GitHubのログインボタンを作成します。

<script lang="ts" setup>
import useAuth from "@/composable/useAuth";

const { signInWithGithub, error } = useAuth();
</script>

<template>
  <div class="wrapper">
    <button
      class="bg-white hover:bg-gray-100 text-gray-800 font-semibold py-2 px-4 border border-gray-400 rounded shadow"
      @click="signInWithGithub"
    >
      Githubでサインインする
    </button>
    <p v-if="error">{{ error }}</p>
  </div>
</template>

LogoutButtonの実装

ログアウトのコンポーネントも別途用意します。

<script lang="ts" setup>
import useAuth from "@/composable/useAuth";

const { signOut } = useAuth();
</script>

<template>
  <div class="wrapper">
    <button
      class="bg-white hover:bg-gray-100 text-gray-800 font-semibold py-2 px-4 border border-gray-400 rounded shadow"
      @click="signOut"
    >
      ログアウト
    </button>
  </div>
</template>

カスタムフックを利用することで関数の呼び出しだけで実装できます。

チャット本体の実装

チャット部分のコードを実装します。
このコンポーネントは、ログイン・チャットの投稿・編集・削除・リアルタイムで更新される仕組みの実装をした。

  1. インポート、必要なライブラリをインストール
    1. 先に実装したuseDatabase と useAuthをインポート
  2. 変数の初期化
    1. nuxtApp:Nuxtアプリケーションのインスタンスを取得
    2. scrollAreaRef, editingMessageId, inputText, messages: チャットアプリのUIやデータを制御するためのリアクティブな変数を定義
    3. isLoginprofileFromGithub: useAuthフックから取得した認証関連のデータ
    4. data, fetchDatabase, addSupabaseData, deletedSupabaseData, editedSupabaseData, TABLE_NAME: useDatabaseフックから取得したデータベース関連のデータや関数
  3. リダイレクト
    1. ログインしていない場合はログイン画面にリダイレクト
  4. スクロールロジック scrollToBottom
    1. チャットを投稿したあとに、位置情報を最下部に移動するための関数
  5. リアルタイムデータの取得: fetchRealtimeData
    1. Supabaseのリアルタイム機能を利用してデータベースの変更リアルタイムで取得するための関数(コアな部分)
  6. 初期データの取得:onMounted
    1. ページの読み込みタイミングで、データベースのデータを取得
  7. メッセージの操作
    1. onChangeInputText, onSubmitNewMessage, onMessageDeleted, onMessageEdited, onSaveEditedMessage: メッセージの追加、削除、編集を行うための関数を定義しています。
  8. 自動スクロール:watch
    1. Vue 3の watchフックを利用して、メッセージ変数に変更されたらチャットエリアを自動的に最下部にスクロールさせる
<script setup lang="ts">
import type { SupabaseClient } from "@supabase/supabase-js";
import { Database } from "@/types/schema";
import { ref, onMounted, watch } from "vue";
import { dateToString } from "@/utils/dateToString";

import useDatabase from "@/composable/useDatabase";
import useAuth from "@/composable/useAuth";

const nuxtApp = useNuxtApp();
const supabase = nuxtApp.$supabase as SupabaseClient;

const scrollAreaRef = ref(null);
const editingMessageId = ref("");
const inputText = ref("");
const messages = ref<Database["public"]["Tables"]["messages"]["Row"][]>([]);
const { session: isLogin, profileFromGithub } = useAuth();
const {
  data,
  fetchDatabase,
  addSupabaseData,
  deletedSupabaseData,
  editedSupabaseData,
  TABLE_NAME,
} = useDatabase();

const router = useRouter();

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

const scrollToBottom = () => {
  if (scrollAreaRef.value) {
    scrollAreaRef.value.scrollTop = scrollAreaRef.value.scrollHeight;
  }
};

const fetchRealtimeData = () => {
  try {
    supabase
      .channel("table_postgres_changes")
      .on(
        "postgres_changes",
        {
          event: "*",
          schema: "public",
          table: TABLE_NAME,
        },
        (payload) => {
          if (payload.eventType === "INSERT") {
            const { createdAt, id, message, avatarUrl, nickName } = payload.new;
            messages.value = [
              ...messages.value,
              { createdAt, id, message, avatarUrl, nickName },
            ];
          }
          if (payload.eventType === "DELETE") {
            const { id } = payload.old;
            messages.value = messages.value.filter((item) => item.id !== id);
          }
          if (payload.eventType === "UPDATE") {
            const { id, message } = payload.new;
            messages.value = messages.value.map((item) =>
              item.id === id ? { ...item, message } : item,
            );
          }
        },
      )
      .subscribe(); // subscribeで初期化作業が必要
  } catch (error) {
    console.error(error);
  }
};

// 初回のみ全データフェッチとリアルタイムリスナー登録
onMounted(async () => {
  await fetchDatabase();
  messages.value = !data.value ? [] : data.value;
  fetchRealtimeData();
  await nextTick();
  scrollToBottom();
});

const onChangeInputText = (event: Event) => {
  const target = event.target as HTMLInputElement;
  inputText.value = target.value;
};

const onSubmitNewMessage = (event: Event) => {
  event.preventDefault();
  if (!inputText.value) return;
  addSupabaseData({ message: inputText.value, ...profileFromGithub.value });
  inputText.value = "";
};

const onMessageDeleted = async (id: string) => {
  await deletedSupabaseData(id);
};

const onMessageEdited = (id: string) => {
  editingMessageId.value = id;
};

const onSaveEditedMessage = async (id: string, newMessage: string) => {
  await editedSupabaseData(id, newMessage); // DBの更新ロジックを実装する
  editingMessageId.value = ""; // 編集モードを終了
};

watch(messages, () => {
  scrollToBottom();
});
</script>
<template>
  <div class="flex flex-col h-screen">
    <div
      ref="scrollAreaRef"
      class="flex-grow overflow-y-auto pt-20 pb-20 px-4 bg-gray-100"
    >
      <div
        class="mt-8"
        v-for="item in messages"
        :key="item.id"
        :data-my-chat="item.nickName === profileFromGithub.nickName"
      >
        <div
          :class="[
            'flex',
            'items-start',
            'mb-2',
            item.nickName !== profileFromGithub.nickName && 'flex-row-reverse',
          ]"
        >
          <div
            :class="[
              'flex-shrink-0',
              item.nickName === profileFromGithub.nickName ? 'mr-3' : 'ml-3',
            ]"
          >
            <a
              :href="`https://github.com/${item.nickName}`"
              target="_blank"
              rel="noopener noreferrer"
            >
              <img
                v-if="item.avatarUrl"
                :src="item.avatarUrl"
                :alt="item.nickName ? item.nickName : '名無し'"
                class="w-10 h-10 rounded-full border"
              />
              <nuxt-img
                v-else
                src="/noimage.png"
                alt="no image"
                class="w-10 h-10 rounded-full border"
              />
            </a>
          </div>
          <div
            class="relative bg-blue-100 text-blue-900 p-3 rounded-lg min-w-[320px]"
          >
            <p class="absolute left-0 -top-4 z-10 text-[10px]">
              {{ item.nickName }}
            </p>
            <div v-if="item.id !== editingMessageId">
              <p>{{ item.message }}</p>
            </div>
            <div v-else>
              <input
                type="text"
                class="border border-gray-400 p-2 rounded w-full"
                v-model="item.message"
                @blur="onSaveEditedMessage(item.id, item.message as string)"
              />
            </div>
            <p class="absolute text-right text-[10px] -bottom-4 right-0">
              {{ dateToString(item.createdAt, "YYYY/MM/DD HH:mm") }}
            </p>
            <button
              class="absolute text-[10px] left-0 -bottom-4"
              @click="onMessageDeleted(item.id)"
              v-if="item.nickName === profileFromGithub.nickName"
            >
              削除
            </button>
            <button
              class="absolute text-[10px] left-7 -bottom-4"
              @click="onMessageEdited(item.id)"
              v-if="item.nickName === profileFromGithub.nickName"
            >
              編集
            </button>
          </div>
        </div>
      </div>
    </div>
    <div class="border-t border-gray-400 bg-gray-200 p-4">
      <form @submit.prevent="onSubmitNewMessage">
        <div class="flex justify-between items-center">
          <div class="w-10/12">
            <input
              type="text"
              name="message"
              v-model="inputText"
              @input="onChangeInputText"
              class="border border-gray-400 w-full p-2 rounded"
              aria-label="新規メッセージを入力"
            />
          </div>
          <div class="w-2/12 ml-5">
            <button
              type="submit"
              :disabled="!inputText"
              class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 border border-blue-700 rounded w-full"
            >
              送信
            </button>
          </div>
        </div>
      </form>
    </div>
  </div>
</template>

Supabaseのメソッド

  • supabase.channelは、Supabaseのリアルタイム機能を使用する際に、特定のデータベーステーブルの変更を受け取るためにチャンネルを設定するメソッド
    • ※リアルタイムのデータベースの変更を取得する必要がない場合・他の方法でリアルタイムのデータベースの変更取得する方法がある場合は不要
  • .on("postgres_changes", {...}, (payload) => {...}): postgres_changes` というイベントは、データベースの変更が検出されたら呼び出される
  • イベントのフィルタリング
    • { event: "*", schema: "public", table: TABLE_NAME } データベースの変更をリアルタイムで取得するかを指定する。publicスキーマのメッセージテーブルのすべてのイベントを取得するようにした。
  • イベントハンドラ
    • INSERT・DELETE・UPDATEに応じて、messagesを追加・変更・削除を行う
  • サブスクリプションの開始
    • .subscribe リアルタイムのデータベースの変更を取得するサブスクリプションが開始される

app.vueの実装

今回は、1画面のため app.vue に作成したコンポーネントを読み込みました。

<script setup>
import { computed } from "vue";
import useAuth from "@/composable/useAuth";
import SignInGithub from "@/components/Feature/SignInGithub.vue";
import ChatApp from "@/components/Feature/ChatApp.vue";
import LogoutButton from "@/components/Feature/LogoutButton.vue";

const { session } = useAuth();
const isLogin = computed(() => !!session.value);
</script>

<template>
  <div
    class="relative h-screen flex justify-center items-center bg-gray-100"
    v-if="!isLogin"
  >
    <!-- Centered Content -->
    <div class="absolute inset-0 flex justify-center items-center">
      <div class="bg-white p-10 rounded shadow-md">
        <h1 class="text-3xl font-bold underline mb-4">
          This is a Vue 3 + Vite + Supabase + Tailwind CSS + TypeScript + Chat
        </h1>
        <SignInGithub />
      </div>
    </div>
  </div>
  <!-- ChatApp Component (will be overlaid above the centered content) -->
  <ChatApp v-if="isLogin" />

  <!-- LogoutButton (positioned top-right of ChatApp) -->
  <div class="fixed top-4 right-4">
    <LogoutButton v-if="isLogin" />
  </div>
</template>

まとめ

今回は、Nuxt3 + Supabase + Tailwind CSS + TypeScript + Chatの実装を行いました。
Vercelなどにデプロイすることで、チャットアプリを公開することができます。

実装自体は、参考記事の写経になりますが、Nuxt3で実装する場合の参考になればと思います。

先日勉強会でも話した内容になるので、スライドもこちらに貼っておきます。

https://speakerdeck.com/redamoon/supabase-plus-nuxt3-noshi-zhuang-su-zhen-ride-tiyatutoapuriwozuo-tutemita

細かい実装はリポジトリを公開していますので、参考にしてみてください。

https://github.com/redamoon/supabase-nuxt3-chat

株式会社HAMWORKS

Discussion