Supabase + Nuxt 3でチャットアプリを作ってみた
Supabase + Nuxt 3でチャットアプリを作ってみたの作成手順やコードについてまとめました。
Qiitaには以前投稿していたのですが、zennでは初投稿です。
Supabase で何か作るという目的でチャットアプリをつくってみました。
この動画は、デモ動画です。
こちらの記事を参考にさせていただきました。
同じフレームワークでも良かったのですが、他のフレームワークではどのように構築するのか考察したかったので Nuxt 3 で作ってみました。
実装中のスクラップメモしていた内容を記事化しました。
Supabase とは
オープンソースのFirebase代替品として作られたクラウドベースのプラットフォームです。
開発者が作るWebアプリケーションに必要なツールやサービスを提供します。
Supabaseで出来ること
- オープンソースのリアルタイムデータベース
- アプリケーションに必要なバックエンドの機能を提供するプラットフォーム
- PostgreSQLをベースとしたリアルタイムデータベースを提供
- データの変更をリアルタイムで購読することができる
- PostgreSQLの機能やエコシステムを活かせるため拡張性が高い
- ユーザサインアップ・ログイン・認証トークン管理などの一般的な認証機能をサポート
- ファイルアップロード・ダウンロード・管理といったストレージ機能を提供
- フロントエンドの疎通にはRESTful API や GraphQLを提供するため、独自のAPI構築が不要
- リアルタイムで購読し、フロントエンドへの反映が可能
- オープンソースのためGitHubでコードを確認することができる
- 完全オープンソースのため、Firebaseよりもカスタマイズ・拡張性が容易
作ったもの
- Supabase と Nuxt 3 を利用したWebチャットアプリ
リポジトリ
作成したリポジトリは以下になります。
スタイルは、Tailwindを利用しています。
環境
- nuxt 3.7.1
- Node.js v18.17.0
- npm 9.8.1
チャットアプリの構成
- ログイン(GitHub)
- 投稿・編集・削除機能
Supabaseの設定
マネージドホスティングサービスの Supabase を利用します。
アカウントを作成して、プロジェクトとDBを作成します。
マネージドホスティングサービスは、フリープランで 2プロジェクト まで作成可能です。
Supabaseプロジェクトの作成
-
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
公式が提供しているクライアントをインストールします。
インストールすることで、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を発行しておきます。
- 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
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
を作成します。
ここでの処理は、メッセージログの取得・追加・削除・編集の処理を定義します。
- インポート、必要なライブラリをインストール
- カスタムフックの定義:
useSupabase
- 変数の初期化
- nuxtApp:Nuxtアプリケーションのインスタンスを取得
- data:データベースから取得したメッセージデータを保持するためのリアクティブな変数
- TABLE_NAMEは、supabsaeで作成したテーブル名
-
fetchDatabases
でデータベースからのデータの取得- ログイン後過去のチャットのやり取りを取得するための関数(ログイン後、データセットされる)※本来であれば、全データ取得せずlimitを利用しスクロールに応じてデータを再取得するが今回は未対
-
deletedSupabaseData
でメッセージの削除- 自分自身が投稿したメッセージを削除する機能
-
editedSupabaseData
でメッセージの編集- 自分自身の投稿メッセージを編集する機能
-
addSupabaseData
でメッセージを追加- input textで入力送信で、メッセージを投稿する機能
- 戻り値
- カスタムフックから返す値を定義
- データやデータベースの操作する関数を提供
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
にアクセスすることで、認証プロバイダの設定ができます。
ブロバイダの設定は、以下を参考に設定してください。
ログイン認証も同様にコードが長くなるため、カスタムフックとして定義しました。
- サインイン
- サインアウト
- 認証状態の監視
- プロフィール情報の取得
composable/useAuth.ts
を作成します。
ここでの処理は、ログイン・ログアウト・プロフィールの取得を定義します。
- インポート、必要なライブラリをインストール
- カスタムフックの定義:
useAuth
- 変数の初期化
- nuxtApp:Nuxtアプリケーションのインスタンスを取得
- sessionとerror:ユーザのセッション情報とエラーメッセージをリアクティブな変数として定義
- 認証状態の監視:onMountedフック内で、Supabaseの認証状態変更されたときにセッション情報を更新する設定
- GitHubでのサインイン
-
signInWithGitHub
関数は、ユーザをGitHubのOAuthを使用してサインインする関数
-
- GitHubプロフィール情報を取得
-
profileFromGitHub
は、GitHubのユーザ名とアバターURLを取得するための関数
-
- サインアウト
-
signOut
関数を定義して、ユーザのサインアウトさせる関数
-
- 戻り値
- カスタムフックから返す値を定義
- エクスポートは、呼び出し側で利用するための設定
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>
カスタムフックを利用することで関数の呼び出しだけで実装できます。
チャット本体の実装
チャット部分のコードを実装します。
このコンポーネントは、ログイン・チャットの投稿・編集・削除・リアルタイムで更新される仕組みの実装をした。
- インポート、必要なライブラリをインストール
- 先に実装したuseDatabase と useAuthをインポート
- 変数の初期化
- nuxtApp:Nuxtアプリケーションのインスタンスを取得
-
scrollAreaRef
,editingMessageId
,inputText
,messages
: チャットアプリのUIやデータを制御するためのリアクティブな変数を定義 -
isLogin
とprofileFromGithub
:useAuth
フックから取得した認証関連のデータ -
data
,fetchDatabase
,addSupabaseData
,deletedSupabaseData
,editedSupabaseData
,TABLE_NAME
:useDatabase
フックから取得したデータベース関連のデータや関数
- リダイレクト
- ログインしていない場合はログイン画面にリダイレクト
- スクロールロジック
scrollToBottom
- チャットを投稿したあとに、位置情報を最下部に移動するための関数
- リアルタイムデータの取得:
fetchRealtimeData
- Supabaseのリアルタイム機能を利用してデータベースの変更リアルタイムで取得するための関数(コアな部分)
- 初期データの取得:onMounted
- ページの読み込みタイミングで、データベースのデータを取得
- メッセージの操作
-
onChangeInputText
,onSubmitNewMessage
,onMessageDeleted
,onMessageEdited
,onSaveEditedMessage
: メッセージの追加、削除、編集を行うための関数を定義しています。
-
- 自動スクロール:watch
- 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で実装する場合の参考になればと思います。
先日勉強会でも話した内容になるので、スライドもこちらに貼っておきます。
細かい実装はリポジトリを公開していますので、参考にしてみてください。
Discussion