Next.js(App Router)×PlanetScale×Drizzle ORMでチャットアプリの構築
今回の記事では、Slack 風なチャットアプリを Next.js(App Router) と最近流行りのライブラリをもりもりに使って実装したので、その振り返りとして記事にまとめてみました!
※ サンプルコードだけ見たい方は、一番最後にリンクを貼っています。
👨👩👧👧 この記事のおすすめ読者
・簡易的なチャットアプリを作りたい方
・Next.js と相性のいい/流行りのライブラリを知りたい方
・Next.js(App Router)のサンプルコードが見たい方
📚 使用技術
Next.js(App Router)
Nextjs14からstableになったServer ActionsとRoute Group、Route Handlerを使ってみたかったのでApp Routerを採用しています。
実際使ってみた感想として、Route Groupはページ管理がしやすくて好きです。他の機能も開発体験は良く使いやすいです。
Shadcn UI
デザインもシンプルで、アコーディオンやタブなどのコンポーネントを利用できるかつオーバライドも簡単にできるので非常にUI構築に便利です。
Drizzle ORM
DrizzleはヘッドレスTypeScript ORMで、シンプルかつ効果的なORM。
SQLライクなクエリとリレーショナルAPIを組み合わせ、高性能と柔軟性を持っており、サーバーレスに対応し、複数のデータベースと接続が可能です。
今最も勢いのあるORMです。個人的にはPrismaより好みです。
PlanetScale(データベース)
PlanetScaleは、開発者の使い勝手を損なうことなく、スケールアップ、高性能、信頼性を提供するMySQL互換のサーバーレスデータベースです。このデータベースを使用することで、水平分割のメリットを享受しながら、スキーマの変更に伴う障害や、実装の複雑さを避けつつ、多くの高度なデータベース機能を手軽に利用できます。(ドキュメントより)
データベースにブランチ機能があり、ロールバック等の手間も削減されると思います。
Clerk(認証)
こちらも最近人気の出てきている認証ライブラリで、App Routerとも相性がいいです。
コンポーネントを貼り付けるだけで、すぐに認証周りは完成します。
また、カスタマイズもGUI上で簡単にできるのでおすすめです。
SWR(React Hooks ライブラリ)
データ取得のための React Hooks ライブラリ。
Route Handlerで作成したAPIを簡単に呼び出すことができます。
Bun(パッケージマネージャー)
最近リリースされたオールインワンキットと呼ばれるBun。
パッケージのインストールやテスト、デバックがかなり高速です。
🚀 完成プロダクト
📝 機能要件
今回実装したチャットアプリの機能要件は以下です。
・新規登録/ログイン
・パブリックチャンネル作成
・メッセージの送受信
・登録ユーザーの表示
今後追加していくとしたら…
・プライベートチャネル作成
・リアルタイムの双方向通信 <- 1番実装したかったけど、Nextjs14とsocket.ioのドキュメントが少なくて諦めです。
・DM 機能
・個人プロフィールの編集
・画像/動画の送信
👨💻 テーブル設計
users
)
ユーザー情報テーブル (フィールド名 | 説明 | 型 (Type) | 制約 (Constraint) |
---|---|---|---|
id | ユーザーID | varchar | Primary Key, Not Null, Default: createId() |
メールアドレス | text | Not Null | |
name | 名前 | text | |
avatar | アバター | text | |
createdAt | 作成日時 | timestamp | Default: Now |
channels
)
チャンネル情報テーブル (フィールド名 | 説明 | 型 (Type) | 制約 (Constraint) |
---|---|---|---|
id | チャンネルID | varchar | Primary Key, Not Null, Default: createId() |
ownerId | オーナーID | varchar | Not Null |
name | チャンネル名 | text | Not Null |
description | 説明 | text | |
createdAt | 作成日時 | timestamp | Default: Now |
channelMembers
)
チャンネルメンバー情報テーブル (フィールド名 | 説明 | 型 (Type) | 制約 (Constraint) |
---|---|---|---|
channelId | チャンネルID | varchar | |
userId | ユーザーID | varchar | |
createdAt | 参加日時 | timestamp | Default: Now |
messages
)
メッセージ情報テーブル (フィールド名 | 説明 | 型 (Type) | 制約 (Constraint) |
---|---|---|---|
id | メッセージID | varchar | Primary Key, Not Null, Default: createId() |
channelId | チャンネルID | varchar | |
userId | ユーザーID | varchar | |
content | 内容 | text | Not Null |
createdAt | 送信日時 | timestamp | Default: Now |
テーブル定義のサンプルコード
Drizzle を使ったテーブル定義は以下の通りです。
import { mysqlTable, primaryKey, text, timestamp, varchar } from "drizzle-orm/mysql-core";
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm";
// ✨ ユーザー情報テーブル
export const users = mysqlTable("users", {
id: varchar("id", { length: 36 })
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
email: text("email").notNull(),
name: text("name"),
avatar: text("avatar"),
createdAt: timestamp("createdAt").defaultNow(),
});
export const usersRelations = relations(users, ({ many }) => ({
channelMembers: many(channelMembers),
}));
// ✨ チャンネル情報テーブル
export const channels = mysqlTable("channels", {
id: varchar("id", { length: 36 })
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
ownerId: varchar("ownerId", { length: 36 }).notNull(),
name: text("name").notNull(),
description: text("description"),
createdAt: timestamp("createdAt").defaultNow(),
});
export const channelsRelations = relations(channels, ({ one, many }) => ({
owner: one(users, {
fields: [channels.ownerId],
references: [users.id],
}),
messages: many(messages),
channelMembers: many(channelMembers),
}));
// ✨ チャンネルメンバー情報テーブル
export const channelMembers = mysqlTable(
"channelMembers",
{
channelId: varchar("channelId", { length: 36 }),
userId: varchar("userId", { length: 36 }),
createdAt: timestamp("createdAt").defaultNow(),
},
t => ({
pk: primaryKey(t.channelId, t.userId),
})
);
export const channelMembersRelations = relations(channelMembers, ({ one }) => ({
user: one(users, {
fields: [channelMembers.userId],
references: [users.id],
}),
channel: one(channels, {
fields: [channelMembers.channelId],
references: [channels.id],
}),
}));
// ✨ メッセージ情報テーブル
export const messages = mysqlTable("messages", {
id: varchar("id", { length: 36 })
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
channelId: varchar("channelId", { length: 36 }),
userId: varchar("userId", { length: 36 }),
content: text("content").notNull(),
createdAt: timestamp("createdAt").defaultNow(),
});
export const messagesRelations = relations(messages, ({ one }) => ({
channel: one(channels, {
fields: [messages.channelId],
references: [channels.id],
}),
user: one(users, {
fields: [messages.userId],
references: [users.id],
}),
}));
💂♀️ 実装内容
実装中に少し躓いた部分のみ紹介していきます。
メッセージ送信時に再検証を行う
getMessage関数は、チャンネル内のメッセージを取得する関数で、createChatはメッセージデータをDBに格納する関数で、どちらもサーバー側で実行される関数です。
revalidateTag
はNext.jsのキャッシュを再検証するためのAPIで、今回はこれを使って
メッセージのキャッシュを再検証して最新状態に保っています。
export const createChat = async (userId: string, channelId: string, formData: FormData) => {
"use server";
const chat = formData.get("chat") as string;
if (chat === "") return; // * メッセージが空の場合は送信しない
try {
// * メッセージの作成
await db.insert(messages).values({
userId,
channelId,
content: chat,
});
// * revalidateTagを追加する
revalidateTag("messages");
} catch (error) {
console.log(error);
}
};
export const getMessage = async (channel: string) => {
if (!channel) throw new Error("Channel not found...");
const messagesData = await db.select().from(messages).where(eq(messages.channelId, channel)).innerJoin(users, eq(messages.userId, users.id)).orderBy(asc(messages.createdAt));
// * revalidateTagを追加する
revalidateTag("messages");
return {
messages: messagesData,
};
};
参考ドキュメント:
新規登録時にWebhookを使ってDB登録する
新規登録時にデータベース上でも格納されるように、webhookを使ってAPI処理を行なっています。
ngrokを使ってローカルサーバーを外部に公開して、公開されたURLをclerkに登録して、新規登録されるたびに関数を走らせています。
/**
* @description : 新規登録時に呼ばれるWebhook
* @doc : https://zenn.dev/hayato94087/articles/bfe72f794e0407
*/
import { NextRequest } from "next/server";
import { Webhook } from "svix";
import { headers } from "next/headers";
import { db } from "@/database";
import { users } from "@/database/schema";
const webhookSecret: string = process.env.NGROK_WEBHOOK_SECRET || "";
type Event = {
data: Record<string, string | number>;
object: "event";
type: EventType;
};
type EventType = "user.created" | "user.deleted";
export async function POST(req: NextRequest) {
const payload = await req.json();
const payloadString = JSON.stringify(payload);
const headerPayload = headers();
const svixId = headerPayload.get("svix-id");
const svixIdTimeStamp = headerPayload.get("svix-timestamp");
const svixSignature = headerPayload.get("svix-signature");
if (!svixId || !svixIdTimeStamp || !svixSignature) {
console.log("svixId", svixId);
console.log("svixIdTimeStamp", svixIdTimeStamp);
console.log("svixSignature", svixSignature);
return new Response("Error occured", {
status: 400,
});
}
const svixHeaders = {
"svix-id": svixId,
"svix-timestamp": svixIdTimeStamp,
"svix-signature": svixSignature,
};
const wh = new Webhook(webhookSecret);
let evt: Event | null = null;
try {
evt = wh.verify(payloadString, svixHeaders) as Event;
} catch (_) {
console.log("error");
return new Response("Error occured", {
status: 400,
});
}
const eventType: EventType = evt.type;
if (eventType === "user.created") {
const { id, ...attributes } = evt.data;
await db.insert(users).values({
id: id as string,
email: (attributes.email_addresses as any)[0].email_address as string,
});
return new Response("success!!", {
status: 201,
});
} else if (eventType === "user.deleted") {
const { id, ...attributes } = evt.data;
console.log("user.deleted: ", id);
return new Response("success!!", {
status: 201,
});
}
}
参考記事:
(かなり丁寧でわかりやすいです!)👾 リポジトリ
全てのコードはこちらで管理しています。
☺️ おわり
最後まで読んでいただきありがとうございます。
少しでもこの記事が参考になって、間接的ですが開発に貢献できれば嬉しいです。
👀 おまけ
弊社では、スマホやPC1つで完結する網羅的な教材と、無制限で解ける本番と同形式の模試で、短期間での資格取得を目指すことができる簿記のアプリ 『Funda簿記』 を運営しています。
少しでも興味のある方がいれば、リンクよりアクセスしていただくか、メールにてお願いします☺️
Discussion