チャットアプリ作成を通して学ぶApp Router/Server Actions
はじめに
App Router が安定版になり、そろそろちゃんと学ばないとな〜と思っている方も多いのではないでしょうか?
また、Server Actions もまだ実験的な機能ですが使えるようになりましたね!
ということで今回は、チャットアプリ作成を通して App Router と Server Actions を学べる記事を書いてみました。
一通りやった後には App Router/Server Actions が分かってきてるようになるのではないかと思います 😀
みなさんの参考になれば嬉しいです!
認証としてClerk、ORM にPrisma、DB にPlanetScaleを使用します。
以下がデモのリポジトリです!
デモサイトです ↓
セットアップ
セットアップを行っていきます。
- いつも通りコマンドで
npx create-next-app@latest
を実行してプロジェクトを作成します。
オプションでApp Router
を選択するようにしてください。 -
yarn dev
でローカルホストを立ち上げます。 - ルート直下の
layout.tsx
を以下のように変更します。
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body className={inter.className}>
<main className="mx-auto flex min-h-screen max-w-5xl flex-col place-content-center justify-between md:p-12">
{children}
</main>
</body>
</html>
);
}
- ルート直下の
page.tsx
を以下のように変更します。
export default async function Home() {
return (
<div className="m-4">
<p>こんにちは</p>
</div>
);
}
-
global.css
の中身を一旦消して白背景に統一します。
Tailwind CSS の設定だけにします。
@tailwind base;
@tailwind components;
@tailwind utilities;
- 以下のような表示になっていればひとまず OK です!
Clerk を使った認証機能の実装
最近話題になっていたClerkを使って認証機能の実装をしていきます。
Clerk を簡単に説明しておくと、NextAuth.js のようなライブラリではなく、サービスとして使う形であり、ユーザー管理は GUI 操作を用いて Clerk のダッシュボードで行います。使ってみた所感としては、認証機能の実装がめちゃくちゃ楽で、ユーザーの管理もダッシュボードで行えるので、Clerk+使いたい DB のような形で、認証と DB を分けたいときに NextAuth.js の代替として有用だと感じました。
プランとしては、Free プラン以外にも Hobby($25/月)/Business($99/月)プランがあります。
今回は Free プランでやっていきます!
Clerk のセットアップ
- 以下 URL からサインインします。
https://dashboard.clerk.com/ - アプリの作成
ダッシューボードに行ったらAdd application
でアプリを新規作成します。Application name に好きなプロジェクト名を入力し、Sign in する方法として今回は Google と GitHub を選択した状態で Create APPLICATION ボタンを押します。 - アプリの作成が完了したら Quickstarts のところで Next.js を選択し、表示されている KEY をコピーします。
そして、プロジェクト直下に.env.local
ファイルを作り、先ほどコピーした KEY たちを貼り付けます。
また、以下の 3 つの環境変数も追加してください。
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/
このファイルは必ず.gitignore に追加して(Next.js の場合はデフォルトで追加されているので OK)public に後悔しないように気をつけてください。 4. アプリの準備はできたので、次に実際にコード側で Clerk を使えるようにしていきます。
以下コマンドで clerk のライブラリをインストールします。
npm install @clerk/nextjs
ひとまずはこれでセットアップ完了です!
ClerkProvoder の作成
Provider を RootLayout に作成し、Context の共有を行います。この Provider を作成することで、どのページからでもセッションやユーザーの取得などを行うことができるようになります。
ここで、RootLayout とは、すべてのページの枠組みを作ることができる Layout ページで、例えば<html>
や<body>
、ヘッダーなど全てのページに共通する部分のレイアウトを書いておくことができます。
そして、同時に Clerk の日本語化対応もしていきます。
+ import { jaJP } from "@clerk/localizations";
+ import { ClerkProvider } from "@clerk/nextjs";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
+ <ClerkProvider localization={jaJP}>
<html lang="ja">
<body className={inter.className}>
<main className="mx-auto flex min-h-screen max-w-5xl flex-col place-content-center justify-between md:p-12">
{children}
</main>
</body>
</html>
+ </ClerkProvider>
);
}
ClerkProvider
の部分に localization
のプロパティでjaJP
を渡して日本語化対応を行っています。
認証された人限定ページの作成
次に、認証された(ログインした)人のみが見れるコンテンツがある場合などはmiddleware
を使って制限をかけられるようにします。
src 直下にmiddleware.ts
ファイルを作成し、中身を以下のようにします。
import { authMiddleware } from "@clerk/nextjs";
export default authMiddleware({
// publicRoutesで、認証されていない人でも見れるページを指定します
// 今回の場合はホームページだけログインしてなくても見れるようにしています
publicRoutes: ["/"],
});
export const config = {
matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
};
より複雑な設定にしたい場合は下記を参考にして設定できると思います!
Sign in ページの作成
サインインページを作っていきます。
ログイン画面の UI は Clerk の方で既に用意されたものを使うことで簡単に構築できます。
app 直下にsign-in
フォルダーを、その配下に[[...sign-in]]
フォルダーを作成します。
この[[...sign-in]]
というフォルダー名の書き方はOptional Catch-all Segments
というもので/sign-in/xxxx
や/sign-in/yyyy/zzzz
だけでなく/sign-in
にもマッチして、ページをキャッチしてくれるものです。
[[...sign-in]]
フォルダは以下にpage.tsx
ファイルを作成します。
ファイルの中身を以下のようにします。
import { SignIn } from "@clerk/nextjs";
export default function Page() {
return (
<div className="flex flex-col items-center">
<SignIn redirectUrl={"/"} />
</div>
);
}
<SignIn />
が Clerk で既に用意されているコンポーネントです。redirectUrl
プロパティを記述することで今回の場合はホームページにリダイレクトするようにしています。
ここで試しに/sign-in
にアクセスしてみましょう。以下のような画面が出たら OK です!
試しにログインしてみてください。ログインに成功したらホームページに飛ぶはずです。
他のライブラリとかだと、ログイン方法として Gogle や GitHub などの外部プロバイダと連携するにはプロバイダ先に行って別の設定が必要になったりする時がありますが、Clerk の場合は特に何もせずに利用可能なので楽でいいですね。
サインイン画面の時に出すアプリのロゴやカラーなどはダッシュボードのCustomization
から変更可能です。また細かいデザインは Tailwind CSS などを使ってappearance
プロパティから調整可能なようです!
ヘッダーの作成
今の状態だと画面が寂しいのでヘッダーを作っていきます。
app 直下に_components
フォルダーを作成します。この_
アンダーバーはルーティングの対象外にしたいファイルに対して書く時に使います。アンダーバーがあるフォルダは以下全てがルーティング対象外となります。
_components
フォルダーの配下にlayout
フォルダー、その配下にHeader.tsx
ファイルを作成します。
ファイルの中身は以下のようにします。
import { SignInButton, SignedIn, SignedOut, UserButton } from "@clerk/nextjs";
import Link from "next/link";
export default function Header() {
return (
<header className="border-b border-gray-200">
<nav className="flex justify-between p-2">
<div className="flex">
<Link href={"/"} className="p-2 text-sm font-semibold md:text-2xl">
🍊ChatLife
</Link>
</div>
<SignedIn>
<UserButton
afterSignOutUrl="/"
/>
</SignedIn>
<SignedOut>
<SignInButton>
<button className="rounded bg-blue-500 px-2 text-white hover:bg-blue-400">
サインイン
</button>
</SignInButton>
</SignedOut>
</nav>
</header>
);
}
<SignedIn />
コンポーネントと<SignedOut />
コンポーネントがあります。
前者はログインした後表示されるもので、中に<UserButtom />
コンポーネントでログインした人のアイコンが表示されるようになります。
後者はログインしていない時に表示されるもので、サインインというボタンを表示しています。
この Header コンポーネントを RootLayout に追加します。
...
+ import Header from "./_components/layout/header/Header";
...
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ClerkProvider localization={jaJP}>
<html lang="ja">
<body className={inter.className}>
+ <Header />
<main className="mx-auto flex min-h-screen max-w-5xl flex-col place-content-center justify-between md:p-12">
{children}
</main>
</body>
</html>
</ClerkProvider>
);
}
以下のような表示になっていれば OK です!
左上のアイコン部分が Header.tsx にある<UserButton />
になります。これもappearance
プロパティがあるのでデザインを変えることも可能です。
アカウント削除ボタンの追加
プロフィールにサインアウトボタンはありますが、アカウント削除ボタンがない状態です。アカウントを削除したいユーザーもいると思うので、アカウント削除ボタンを追加しようと思います。
Clerk ではアカウント削除は backend API を通してのみ実行可能であるため、プロフィールの近くにボタンを追加し、ユーザーはそのボタンをクリックすることでアカウント削除ができるようにします。
プロフィールのアカウントの管理ボタンに飛ぶと、モーダルが表示されると思います。このモーダルとして表示されているコンポーネントは<UserProfile />
になります。
このプロフィールのアカウントページにボタンを追加できればいいのですが、調べた感じだとボタンの追加はできなさそうです。(それ用のプロパティやダッシュボードからの設定などもないので無理そうでした)
そのため、今モーダルと表示しているアカウントページを/account
にアクセスすることで見れるようにして、そこにアカウント削除ボタンを追加することにします。
まず、app 直下にaccount
フォルダーを作成し、その配下にいつも通りpage.tsx
ファイルを作成します。
page.tsx ファイルを以下のようにします。
import { UserProfile } from "@clerk/nextjs";
export default async function Account() {
return (
<div>
<UserProfile />
</div>
);
}
/account
に移動してページを確認してみましょう。
先ほど、モーダルとして表示されていたアカウント画面が出てきていたら成功です。
次に、/account ページの下部にアカウント削除ボタンを作成します。
アカウント削除ボタンをクリックしたときに back-end API を通してでのみ使えるアカウント削除処理を行えるようにするには、Server Actions が持ってこいのため、まずは Server Actions を使えるように、next.config.js
ファイルに以下を追記します。
/** @type {import('next').NextConfig} */
const nextConfig = {
+ experimental: {
+ serverActions: true,
+ },
};
module.exports = nextConfig;
そして、/account/page.tsx
に戻ります。
この Accout 自体は Server Components であり、ボタンをクリックしたら〜の動作(onClick の使用)は直接ここには書くことができません。
そのため、<DeleteUserButton />
は Client Component として別ファイルに切り出しましょう。
/account
フォルダーの下に_components
ファルダーを作成し、その配下にDeleteUserButton.tsx
ファイルを追加します。
ファイルの中身は以下のようにします。
"use client";
import { deleteUserAction } from "@/app/_lib/action";
import { useClerk } from "@clerk/nextjs";
import { useTransition } from "react";
export default function DeleteUserButton({ userId }: { userId: string }) {
const [isPending, startTransition] = useTransition();
const { signOut } = useClerk();
return (
<>
{isPending ? (
<div>loading...</div>
) : (
<button
type="button"
onClick={() =>
startTransition(() => {
//deleteUserAction(userId);
signOut();
})
}
className="rounded bg-orange-400 p-3 text-white hover:bg-orange-300"
>
アカウントを削除する
</button>
)}
</>
);
}
まず、onClick など副作用が発生するので必ずファイルの先頭に"use client";
を宣言し、Client Component であることを示します。
そして、button がクリックされたときなど form action ではない場合に Server Actions を使うにはstartTransition
を使用します。
この startTransition の中にサーバー側で実行したい処理を書きます。
Server Actions をまとめるファイルを作成し、そこにdeleteUser
関数を書くことにしましょう。
app 直下に_lib
フォルダーを作成し、その配下にaction.ts
ファイルを作成します。
ファイルの中身を以下のようにします。
"use server";
import { auth, clerkClient } from "@clerk/nextjs";
export async function deleteUserAction(userId: string) {
if (!userId) return;
await clerkClient.users.deleteUser(userId); // アカウント削除
}
ファイルの先頭で、サーバー側で処理することを明示するために"use server";
を宣言しています。
そして、deleteUser
関数を宣言します。その際に必ずasync
をつけ忘れないようにしてください。つけないと Server Actions は async の functions じゃないとダメだよとエラーになります。
deleteUser
をDeleteUserButton.tsx
でインポートして、コメントアウトを消します。
また、isPending
を使用して、Server Action を実行中の場合はボタンの表示をなくし、「アカウント削除中...」を表示しています。
アカウント削除中だけの表示だと見た目が寂しいのでアニメーションを使います。
/app/_components
配下にui
フォルダーを作成し、その配下にAnimation.tsx
ファイルを作成します。
ファイルの中身を以下のようにします。
export function ThreePointsAnimation() {
return (
<div className="flex justify-center">
<div className="animate-ping h-2 w-2 bg-blue-600 rounded-full"></div>
<div className="animate-ping h-2 w-2 bg-blue-600 rounded-full mx-4"></div>
<div className="animate-ping h-2 w-2 bg-blue-600 rounded-full"></div>
</div>
);
}
その後、ローディングの際にこれを出す!というのを決めるファイルを作成します。
App Router ではページフォルダーごとにloading.tsx
というファイルを定義することで、読み込み時に決まってそのコンポーネントを出すようにすることができます。
今回はapp
直下にloading.tsx
ファイルを作成し、その Loading UI を使用するようにします。
先ほど作った<ThreePointsAnimation />
を返すようにします。
import { ThreePointsAnimation } from "./_components/ui/Animation";
export default function Loading() {
return <ThreePointsAnimation />;
}
次に、/account/page.tsx
に戻り以下のように書き換えます。
import { UserProfile, auth, clerkClient } from "@clerk/nextjs";
+ import DeleteUserButton from "./_components/DeleteUserButton";
export default async function Account() {
const { userId } = auth();
if (!userId) throw new Error("userId is not found");
return (
<div>
<UserProfile />
+ {userId && (
+ <div className="my-16 flex flex-col items-center">
+ <DeleteUserButton userId={userId} />
+ </div>
+ )}
</div>
);
}
auth()
は App Router の Server Components の中でないと使えない関数であるため、ここで userId を取得し、DeleteUserButton
に渡しています。
これで動くようになったと思うので、/account
に移動し、アカウント削除ボタンを押して試してみてください。
最後に、現状だと右上のユーザーボタンのアカウントの管理をクリックすると/accout
ページに飛ぶようになっていないので、Header.tsx
を修正します。
...
export default function Header() {
return (
<header className="border-b border-gray-200">
<nav className="flex justify-between p-2">
...
<SignedIn>
<UserButton
afterSignOutUrl="/"
+ userProfileMode="navigation"
+ userProfileUrl="/account"
/>
</SignedIn>
...
</nav>
</header>
);
}
userProfileMode
で modal ではなく、navigation を指定し、そのナビゲート先として/accout
を指定します。
これで、アカウントの管理をクリックすると/accout
ページに移動するようになりました。
Prisma + PlanetScale の導入
- PlanetScale の導入
DB として今回は PlanetScale を使用します。この DB を使って、チャットルームやチャットの登録を行います。
PlanetScalen の導入は以下の記事を参考に、「Database に接続する準備をする」まで終わらせてください。
- Prisma の導入
今回は ORM として Prisma を使います。
以下のコマンドを実行し、Prisma CLI のインストールとライブラリの追加を行います。
npm install prisma --save-dev
npm install @prisma/client
次に、prisma の初期化をし、schema ファイルの生成を行います。
npx prisma init
プロジェクト直下に/prisma/schema.prisma
と.env
ファイルが作成されたと思います。
.env
ファイルを.gitignore
ファイルに追加するのを忘れないようにやっておきます。
# local env files
.env
.env*.local
次に、PlanetScale の Overview 画面にある Get Connection strings ボタンをクリックして、.env
タブの中身をそのままコピーして.env
ファイルに貼り付けます。また、schema.prisma
ファイルも同様に貼り付けます。
Prisma でスキーマの定義
schema.prisma
ファイルの中身を以下のようにします。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
model User{
id Int @id @default(autoincrement())
uuid String @unique
name String
profileImageUrl String
chats Chat[]
}
model Room{
id Int @id @default(autoincrement())
name String
description String
chats Chat[]
createdAt DateTime @default(now())
}
model Chat{
id Int @id @default(autoincrement())
message String
room Room @relation(fields: [roomId], references: [id])
roomId Int
user User @relation(fields: [userId], references: [id])
userId Int
createdAt DateTime @default(now())
@@index([roomId])
@@index([userId])
}
Room と User テーブルを追加しました。
これを PlanetScale の DB にプッシュするため、以下のコマンドを実行します。
npx prisma db push
PlanetScale 上で以下のようなスキーマが確認できれば連携 OK です!
実際の DB 操作は Prisma Studio で行います。
以下のコマンドを実行し、ローカル上に Prisma Studio を立ち上げましょう。
npx prisma studio
Room テーブルに 2 つデータを追加しておきます。
これでひとまず Prisma + PlanetScale の導入準備は完了しました!
ホームの飾り付けをする
現状、ホームが「こんにちは」の文字しかない寂しい状態であるため飾り付けをしていきます。
ユーザーの名前とプロフィール画像の取得
Clerk で保持しているユーザー情報から名前とプロフィール画像を取得して表示させます。
/app/page.tsx
を以下のように変更します。
import Image from "next/image";
import Link from "next/link";
import { getUser } from "./_lib/clerk";
export default async function Home() {
const user = await getUser();
return (
<div className="m-4">
<div>
<p className="mt-4 text-2xl font-semibold">
こんにちは!{" "}
{user ? (
<Link
href="/account"
className="text-orange-400 hover:border-b-2 hover:border-b-orange-400 "
>
{user?.username}
</Link>
) : (
<span className="text-orange-400">ゲスト</span>
)}
さん
</p>
</div>
{!user && (
<div className="my-6">
<Link
href="/sign-in"
className="rounded bg-blue-500 p-3 text-white hover:bg-blue-400"
>
サインイン
</Link>
</div>
)}
{user && user.profileImageUrl && (
<Image
src={user.profileImageUrl}
width={100}
height={100}
alt="プロフィール画像"
className="my-4"
/>
)}
</div>
);
}
/app/_lib
配下にclerk.ts
ファイルを追加し、そこにユーザーを取得する関数getUser
を定義していきます。
import { auth, clerkClient } from "@clerk/nextjs";
export const getUser = async () => {
const { userId } = auth();
const user = userId ? await clerkClient.users.getUser(userId) : null;
return user;
};
この関数を使うのは Server Componets であるため、back-end API を通してユーザーを取得します。
また、<Link />
コンポーネントの src プロパティに外部 URL(今回の場合だと Google や GitHub から取得するプロフィールアイコン)を指定するとエラーが発生するので、next.config.js
を以下のように変更します。
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.clerk.dev",
port: "",
},
{
protocol: "https",
hostname: "www.gravatar.com",
port: "",
},
],
},
experimental: {
serverActions: true,
},
};
module.exports = nextConfig;
これで、以下のように名前とアイコンが出るようになりました!
ルーム一覧の表示
まだホームが寂しいので、アイコンの下にルーム一覧を表示したいと思います。
app/page.tsx
ファイルを以下のように変更します。
import Image from "next/image";
import Link from "next/link";
import { getUser } from "./_lib/clerk";
+ import Loading from "./loading";
+ import { Suspense } from "react";
+ import Rooms from "./_components/Rooms";
export default async function Home() {
const user = await getUser();
return (
<div className="m-4">
<div>
<p className="mt-4 text-2xl font-semibold">
こんにちは!{" "}
{user ? (
<Link
href="/account"
className="text-orange-400 hover:border-b-2 hover:border-b-orange-400 "
>
{user?.username}
</Link>
) : (
<span className="text-orange-400">ゲスト</span>
)}
さん
</p>
</div>
{!user && (
<div className="my-6">
<Link
href="/sign-in"
className="rounded bg-blue-500 p-3 text-white hover:bg-blue-400"
>
サインイン
</Link>
</div>
)}
{user && user.profileImageUrl && (
<Image
src={user.profileImageUrl}
width={100}
height={100}
alt="プロフィール画像"
className="my-4"
/>
)}
+ <div className="my-20">
+ <Suspense fallback={<Loading />}>
+ <a id="rooms" className="mx-2 text-gray-600">
+ 参加可能なルーム一覧
+ </a>
+ <Rooms />
+ </Suspense>
+ </div>
+ </div>
);
}
<Rooms />
コンポーネントがルーム一覧を表示するもので、DB から非同期に取得して Suspense で囲うようにします。読み込んでいる間は<Loading />
を出力するようにします。
<Rooms />
コンポーネントを作成します。
まず、/app/_components
配下にRooms.tsx
ファイルを作成します。
そして、ファイルの中身を以下のようにします。
import { prisma } from "../_lib/prisma";
import Link from "next/link";
import ChatsNum from "./ChatsNum";
import { JumpIcon } from "./ui/Icon";
export default async function Rooms() {
const rooms = await prisma.room.findMany(); //Roomテーブルから全てのルームを取得
if (!rooms || rooms.length === 0)
return <div className="my-14">sorry... 参加可能なルームはありません🥹</div>;
return (
<div className="my-8 grid gap-4 md:grid-cols-3">
{rooms.map((room) => (
<Link
href={`/room/${room.id}`}
className="flex h-full flex-col justify-between rounded-3xl border p-5 text-left shadow hover:bg-gray-100"
key={room.id}
>
<div className="my-4 flex">
<p className="mr-2 text-sm font-semibold text-blue-500 md:text-base">
{room.name}
</p>
<JumpIcon />
</div>
<p className="text-sm text-gray-500">{room.description}</p>
<ChatsNum id={room.id} />
</Link>
))}
</div>
);
}
<JumpIcon />
と<ChatsNum />
コンポーネントを別で作成します。
<JumpIcon />
は、/app/_components/ui
配下にIcon.tsx
ファイルを作成し、以下のように書きます。
export const JumpIcon = () => {
return (
<svg
className="h-6 w-6 text-gray-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="7" y1="17" x2="17" y2="7" />{" "}
<polyline points="7 7 17 7 17 17" />
</svg>
);
};
export const ChatIcon = () => {
return (
<svg
className="h-5 w-5 text-orange-400"
width="24"
height="24"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
{" "}
<path stroke="none" d="M0 0h24v24H0z" />{" "}
<rect x="3" y="5" width="18" height="14" rx="2" />{" "}
<polyline points="3 7 12 13 21 7" />
</svg>
);
};
export const ArrowBottomIcon = () => {
return (
<svg
className="h-8 w-8 text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
);
};
あとで使う Icon も追加しておきます。
<ChatsNum />
は、/app/_components
配下にChatsNum.tsx
ファイルを作成し、そこに中身を書いていきます。
これは各ルームが持っているチャット数を表示するコンポーネントです。
import { getChatsNumByRoomId } from "../_lib/prisma";
import { ChatIcon } from "./ui/Icon";
export default function ChatsNum({ id }: { id: number }) {
const num = getChatsNumByRoomId(id);
return (
<div className="my-4 flex w-1/5 rounded-full border border-orange-400 px-3 py-2 text-orange-400 md:w-1/3 lg:w-1/4">
<p className="mx-auto block text-left text-sm font-semibold">{num}</p>
<ChatIcon />
</div>
);
}
ルーム ID からそのルームが持っているチャット数を取得する関数getChatsNumByRoomId
を定義するために、/_lib/prisma.ts
を作成します。
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
export const getChatsNumByRoomId = (id: number) => {
const count = prisma.chat.count({
where: {
roomId: id,
},
});
return count;
};
これで、以下のようにルーム一覧が表示されるようになりました!
各ルームページの作成
Rooms
コンポーネントの各ルームへのリンクにhref={
/room/${room.id}}
と指定しています。このリンク先となるページを作っていきます。
app
直下に/room/[id]
フォルダー、その配下にpage.tsx
ファイルを作成します。
import { getUser } from "@/app/_lib/clerk";
import Loading from "@/app/loading";
import { formatDate, getChats, getRoomById } from "@/app/_lib/prisma";
import { auth } from "@clerk/nextjs";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import Chats from "./_components/Chats";
import Form from "./_components/Form";
export default async function Room({ params }: { params: { id: string } }) {
const room = await getRoomById(parseInt(params.id));
if (!room) {
return <div>ルームが見つかりませんでした</div>;
}
const createdDate = room.createdAt ? formatDate(room.createdAt) : undefined;
const user = await getUser();
if (!user) {
redirect("/sign-in");
}
const userName =
user.username && user.username !== "" ? user.username : "ゲスト";
const chats = await getChats(parseInt(params.id));
return (
<div className="mx-4 text-center">
<div className="border-b border-b-gray-300 py-4 text-left">
<h2 className="my-2 text-base md:text-2xl">{room.name}</h2>
<div className="flex justify-between text-gray-500">
<p className="text-xs md:text-sm">{room.description}</p>
{createdDate && <p>作成日 : {createdDate}</p>}
</div>
</div>
<div className="my-6 md:my-8">
<Suspense fallback={<Loading />}>
{/* @ts-expect-error Async Server Component */}
<Chats chats={chats} />
<Form
roomId={parseInt(params.id)}
uuid={user.id}
username={userName}
profileImageUrl={user.profileImageUrl}
/>
</Suspense>
</div>
</div>
);
}
params
で URL から取得した id が入った状態でルーム ID を取得します。例えば、/room/1
にアクセスした時、このid="1"
が取得できています。
また、<Form />
コンポーネントに渡している引数としてuuid
やusername
、profileImageUrl
がありますが、なぜUser
型の user をそのまま渡さずに分けているかというと、 Server Component から Client Component(今回の場合は Form)にプロパティを渡すときは、JSON 形式にパースできる文字列でないとコンソールに warning が発生するからです。user オブジェクトをそのまま渡したいときは以下のようにプラグインを使ってシリアライズするしかなさそうです。
この id を使って、ルームを取得する関数getRoomById
とルーム作成日をフォーマットする関数formatDate
、チャットを取得する関数getChats
、User テーブルの id からユーザーを取得する関数getUserById
を/_lib/prisma.ts
ファイルに以下のように追加します。
...
export const getRoomById = (id: number) => {
const room = prisma.room.findUnique({
where: { id: id },
select: {
name: true,
description: true,
createdAt: true,
chats: true,
},
});
return room;
};
export const getChats = (roomId: number) => {
const chats = prisma.chat.findMany({
where: {
roomId: roomId,
},
orderBy: {
createdAt: "asc",
},
});
return chats;
};
export const formatDate = (date: Date) => {
const [year, month, day] = [
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
];
return `${year}/${month}/${day}`;
};
export function getUserById(id: number) {
const user = prisma.user.findUnique({
where: { id: id },
select: {
uuid: true,
name: true,
profileImageUrl: true,
},
});
return user;
}
<Chats />
コンポーネントと<Form />
コンポーネントを作成していきます。
[id]
フォルダー配下に_components
フォルダーを、その配下にChats.tsx
とForm.tsx
ファイルを作成します。
Chats.tsx
ファイルは以下のようにします。
import { ArrowBottomIcon } from "@/app/_components/ui/Icon";
import { getUserById } from "@/app/_lib/prisma";
import { auth } from "@clerk/nextjs";
import { Chat } from "@prisma/client";
import Image from "next/image";
export default async function Chats({ chats }: { chats: Chat[] }) {
if (!chats || chats.length === 0)
return (
<div className="flex flex-col items-center justify-center gap-3">
<p>チャットがありません🥺</p>
<p>みんなに話しかけてみましょう!</p>
<ArrowBottomIcon />
</div>
);
const chatList = await Promise.all(
chats.map(async (chat) => {
// PrismaでChatテーブルのuserIdからUserテーブルのuserを取得する
const user = await getUserById(chat.userId);
return {
id: chat.id,
name: user?.name,
profileImageUrl: user?.profileImageUrl,
message: chat.message,
isMe: user?.uuid === auth().userId,
};
})
);
return (
<div className="h-96 overflow-auto text-left">
{chatList.map((chat) => {
return (
<div
key={chat.id}
className={`flex items-start p-2 md:p-3 ${
chat.isMe ? "flex-row-reverse" : ""
}`}
>
<div className="flex flex-col items-center">
{chat.profileImageUrl ? (
<Image
src={chat.profileImageUrl}
width={30}
height={30}
alt={chat.name ?? ""}
className="h-8 w-8 md:w-12 md:h-12"
/>
) : (
<div>😃</div>
)}
<p className="text-center text-xs text-gray-500 md:text-sm">
{chat.name ?? "ゲスト"}
</p>
</div>
<p className="mx-1 justify-self-stretch p-4 text-xs md:mx-4 md:text-sm">
{chat.message}
</p>
</div>
);
})}
</div>
);
}
Form.tsx
は以下のようにします。
"use client";
import { createChatAction, upsertUserAction } from "@/app/_lib/action";
import { useRef, useState } from "react";
export default function Form({
roomId,
uuid,
username,
profileImageUrl,
}: {
roomId: number;
uuid: string;
username: string;
profileImageUrl: string;
}) {
const [ispending, setIspending] = useState(false);
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
setIspending(true); // 送信ボタンを非活性にする
setError(null);
const message = formData.get("message");
if (!message || typeof message !== "string") {
setError("1文字以上の文字列を入力してください");
setIspending(false);
return;
}
// Server Actionの実行
// PrismaのUserテーブルを更新または作成する
const userData = await upsertUserAction(uuid, username, profileImageUrl);
await createChatAction(roomId, userData.id, message);
setIspending(false);
formRef.current?.reset();
}
if (ispending) {
return <Loading />;
}
return (
<div className="my-10 bg-white">
<form
className="flex items-center border-t border-gray-200 p-4"
action={handleSubmit}
ref={formRef}
>
{error && (
<div>
<p className="text-red-500">{error}</p>
</div>
)}
<input
type="text"
name="message"
autoComplete="off"
className="flex-grow rounded-lg border px-4 py-2 focus:border-blue-300 focus:outline-none focus:ring"
placeholder="メッセージを入力..."
/>
<button
type="submit"
disabled={ispending}
className="rounded-lg bg-blue-500 hover:bg-blue-400 px-2 md:px-4 py-2 text-white text-xs md:text-sm focus:border-blue-300 focus:outline-none focus:ring w-1/6"
>
送信
</button>
</form>
</div>
);
}
"use client";
で Client Component として定義します。
サーバー側で取得した roomId と user(Clerk からの取得)を受け取るようにしています。
バリデーションはちゃんとやるなら Zod などを使ってやったほうがいいかもです。
form action で handleSubmit 関数を指定しており、その中でボタンの非活性やエラーの制御、Server Action 関数の実行を行っています。
upsertUserAction
とcreateChatAction
関数をaction.ts
ファイルに定義していきます。
...
+import { revalidatePath } from "next/cache";
+import { createChat, createUser } from "./prisma";
...
+export async function upsertUserAction(
+ uuid: string,
+ name: string,
+ profileImageUrl: string
+) {
+ return await createUser(uuid, name, profileImageUrl);
+}
+export const createChatAction = async (
+ roomId: number,
+ userId: number,
+ message: string
+) => {
+ await createChat(roomId, userId, message);
+};
prisma.ts
に、action.ts
で使われている createUserと
createChat`関数を追加します。
export function createUser(
uuid: string,
name: string,
profileImageUrl: string
) {
const user = prisma.user.upsert({
where: { uuid: uuid },
update: {
name: name,
profileImageUrl: profileImageUrl,
},
create: {
uuid: uuid,
name: name,
profileImageUrl: profileImageUrl,
},
});
return user;
}
export function createChat(roomId: number, userId: number, message: string) {
const chat = prisma.chat.create({
data: {
roomId: roomId,
userId: userId,
message: message,
},
});
return chat;
}
prisma.user.upsert
部分の upsert はupdate
とinsert
の両方から取ったもので、User テーブルにすでにデータが存在している場合は更新を、存在していない場合は新規作成を行なってくれるものです。
ホームにあるルーム一覧から適当なルームに飛んでみると以下のような表示になっていると思います。
この状態でチャットを送信してみると、何も表示が変わらないのでどこかがおかしいです。
この理由は、Server Actions でサーバー側の処理を行った時に、再検証がされておらずキャッシュが残った状態になっており、クライアント側の表示が変わっていないからです。
そのため、再検証(revalidate)を行うために、revalidatePath
関数を action.ts のcreateChatAction
関数の最後に追記します。
+import { revalidatePath } from "next/cache";
...
export const createChatAction = async (
roomId: number,
userId: number,
message: string
) => {
await createChat(roomId, userId, message);
+ revalidatePath(`/room/${roomId}`);
};
revalidatePath
は以下で詳しい説明が見れます。
これで、再度チャットを送信してみると再検証が行われてチャットが表示されるはずです。
ルーム作成ページの作成
いよいよ疲れてきちゃったのですが、あと一息で終わります。
次はルーム作成のページを作ります。
/app/room
配下にcreate
フォルダーを、その配下にpage.tsx
を作成します。
"use client";
import { createRoomAction } from "@/app/_components/action";
import { redirect } from "next/navigation";
import { useState } from "react";
export default function Create() {
const [ispending, setIspending] = useState(false);
const [error, setError] = useState<string | null>(null);
// 🤨isPendingがtrueの時にローディングUIを出そうとするとredirectしなくなってしまう
async function handleSubmit(formData: FormData) {
setIspending(true);
setError(null);
const [name, description] = [
formData.get("name"),
formData.get("description"),
];
if (
!name ||
typeof name !== "string" ||
!description ||
typeof description !== "string"
) {
setError("1文字以上の文字列を入力してください");
setIspending(false);
return;
}
const room = await createRoomAction(name as string, description as string);
setIspending(false);
redirect(`/room/${room.id}`);
}
return (
<div className="mx-4 my-10 flex flex-col-reverse bg-white md:flex-row">
<form className="flex basis-1/2 flex-col gap-10" action={handleSubmit}>
{error && (
<div>
<p className="text-red-500">{error}</p>
</div>
)}
<div>
<label htmlFor="name">
🍊ルームの名前<span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
autoComplete="off"
className="w-full rounded-lg border px-4 py-2 my-3 focus:border-blue-300 focus:outline-none focus:ring"
placeholder="メッセージを入力..."
/>
</div>
<div>
<label htmlFor="description">
🍊どんなルームですか?<span className="text-red-500">*</span>
</label>
<textarea
id="description"
name="description"
autoComplete="off"
className="h-40 w-full rounded-lg border px-4 py-2 my-3 focus:border-blue-300 focus:outline-none focus:ring"
placeholder="メッセージを入力..."
/>
</div>
<button
type="submit"
disabled={ispending}
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-400"
>
ルームをつくる
</button>
</form>
</div>
);
}
先ほどのチャット送信と同じ容量で form に対して Server Action を実行します。
createRoomAction
関数をaction.ts
ファイルに追記します。
export const createRoomAction = async (name: string, description: string) => {
const room = await createRooms(name, description);
return room;
};
そして、createRooms
関数をprisma.ts
ファイルに追記します。
export const createRooms = (name: string, description: string) => {
const prisma = new PrismaClient();
const room = prisma.room.create({
data: {
name: name,
description: description,
},
});
return room;
};
このフォームの右側が寂しいので何か画像などを追加してもいいかもです!
/room/create
にアクセスしてルームを作ってみると、作成後、作ったルームのページにリダイレクトしたら OK です。
ヘッダーの改修
/room/create
のページにアクセスできるリンクがまだないので、ヘッダーに作成します。
/app/_components/layout
配下にheader
フォルダーを作成し、そこにHeader.tsx
を移動するのと同時に、LinkList.tsx
ファイルを作成します。
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const linkList = [
{
href: "/room/create",
text: "ルームをつくる",
},
];
export default function LinkList() {
const pathname = usePathname();
return (
<>
{linkList.map((link, index) => {
const isActive = pathname.startsWith(link.href);
return (
<Link
key={index}
href={link.href}
className={`mx-1 rounded-2xl px-2 py-1.5 text-sm md:my-2 md:px-4 ${
isActive
? "bg-orange-200 text-orange-600"
: "text-gray-500 hover:bg-orange-200 hover:text-orange-600"
}`}
>
{link.text}
</Link>
);
})}
</>
);
}
usePathname
関数は現在の URL パスを取得し、そのパスが linkList
で設定している href と同じかどうかでデザインを変えています。
この<LinkList />
コンポーネントをHeader.tsx
に追加します。
...
+import LinkList from "./LinkList";
export default function Header() {
return (
<header className="border-b border-gray-200">
<nav className="flex justify-between p-2">
<div className="flex">
<Link href={"/"} className="p-2 text-sm font-semibold md:text-2xl">
🍊ChatLife
</Link>
+ <LinkList />
</div>
...
</nav>
</header>
);
}
Header.tsx
のパスが変わったので/app/layout.tsx
ファイルの Header.tsx インポートも修正します。
これでヘッダーにルームをつくるボタンが作成されました。
デプロイする
いよいよ最後です!デプロイします。
まず、PlanetScale のダッシュボードに行き、main ブランチ画面でPromote to production
ボタンをクリックします。
次に、ビルド時に発生する Prisma のエラー解消のため、package.json
ファイルに以下を追記します。
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
+ "postinstall": "prisma generate"
},
そして、このプロジェクトを GitHub 等で push しましょう。
次に Vercel で新しいプロジェクトを作成し、Environment Variables
に.env
と.env.local
ファイルで定義した Key と Value を追加します。
あとはビルドして最後まで通したらデプロイ完了です!
終わりに
これでチャットアプリは一通り機能するところまでいけたと思います。
チャットはリアルタイムで監視していないので、チャットアプリというよりかは掲示板に近いかもです。
リアルタイムで監視するには<Chats />
コンポーネントを Client Component にしてなんやかんやすればいけると思います。
今回のアプリ作成を通して、APP Router はまだまだ production で使うには早いかなとも思いました。
例えば、next/link
を使ってページ遷移する場合に必ず soft navigation になってしまってキャッシュがクリアされず、最新でないデータが表示されてしまうことです。
next/link
のキャッシュについては以下で議論されていて、将来的に<Link />
コンポーネントにプロパティを追加して必ず revalidate するなど何らかの対応がされればいいなと思います。
最後に、この記事を通して、「ここ間違ってるよ!」や「もっとこうしたほうがいい」みたいなことがあればコメントしてくださると幸いです!
Discussion
とても参考になる記事をありがとうございます!
自分の環境で写経してみたところ、2点ばかり修正が必要でした。
最初のセットアップ
npm install @clerk/clerk-js
->npm install @clerk/nextjs
action.ts
にupsertUserAction
を追加するところでreturn忘れs/await createUser(...)/return await createUser(...)
報告してくださりありがとうございます!
修正いたしました!助かります🙇