🦤

チャットアプリ作成を通して学ぶApp Router/Server Actions

2023/06/04に公開
2

はじめに

App Router が安定版になり、そろそろちゃんと学ばないとな〜と思っている方も多いのではないでしょうか?
また、Server Actions もまだ実験的な機能ですが使えるようになりましたね!
ということで今回は、チャットアプリ作成を通して App Router と Server Actions を学べる記事を書いてみました。
一通りやった後には App Router/Server Actions が分かってきてるようになるのではないかと思います 😀
みなさんの参考になれば嬉しいです!

認証としてClerk、ORM にPrisma、DB にPlanetScaleを使用します。

以下がデモのリポジトリです!
https://github.com/yajium/app-router-server-actions-study

デモサイトです ↓
https://chatlife.vercel.app/

セットアップ

セットアップを行っていきます。

  1. いつも通りコマンドでnpx create-next-app@latestを実行してプロジェクトを作成します。
    オプションでApp Routerを選択するようにしてください。
  2. yarn devでローカルホストを立ち上げます。
  3. ルート直下のlayout.tsxを以下のように変更します。
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>
  );
}
  1. ルート直下のpage.tsxを以下のように変更します。
page.tsx
export default async function Home() {

  return (
    <div className="m-4">
      <p>こんにちは</p>
    </div>
  );
}
  1. global.cssの中身を一旦消して白背景に統一します。
    Tailwind CSS の設定だけにします。
global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
  1. 以下のような表示になっていればひとまず OK です!

こんにちはの表示

Clerk を使った認証機能の実装

最近話題になっていたClerkを使って認証機能の実装をしていきます。

Clerk を簡単に説明しておくと、NextAuth.js のようなライブラリではなく、サービスとして使う形であり、ユーザー管理は GUI 操作を用いて Clerk のダッシュボードで行います。使ってみた所感としては、認証機能の実装がめちゃくちゃ楽で、ユーザーの管理もダッシュボードで行えるので、Clerk+使いたい DB のような形で、認証と DB を分けたいときに NextAuth.js の代替として有用だと感じました。
プランとしては、Free プラン以外にも Hobby($25/月)/Business($99/月)プランがあります。

https://clerk.com/pricing

今回は Free プランでやっていきます!

Clerk のセットアップ

  1. 以下 URL からサインインします。
    https://dashboard.clerk.com/
  2. アプリの作成
    ダッシューボードに行ったらAdd applicationでアプリを新規作成します。Application name に好きなプロジェクト名を入力し、Sign in する方法として今回は Google と GitHub を選択した状態で Create APPLICATION ボタンを押します。
  3. アプリの作成が完了したら Quickstarts のところで Next.js を選択し、表示されている KEY をコピーします。
    そして、プロジェクト直下に.env.localファイルを作り、先ほどコピーした KEY たちを貼り付けます。
    また、以下の 3 つの環境変数も追加してください。
.env.local
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 のライブラリをインストールします。

cmd
npm install @clerk/nextjs

ひとまずはこれでセットアップ完了です!

ClerkProvoder の作成

Provider を RootLayout に作成し、Context の共有を行います。この Provider を作成することで、どのページからでもセッションやユーザーの取得などを行うことができるようになります。
ここで、RootLayout とは、すべてのページの枠組みを作ることができる Layout ページで、例えば<html><body>、ヘッダーなど全てのページに共通する部分のレイアウトを書いておくことができます。

そして、同時に Clerk の日本語化対応もしていきます。

layout.tsx
+ 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ファイルを作成し、中身を以下のようにします。

middleware.ts
import { authMiddleware } from "@clerk/nextjs";

export default authMiddleware({
  // publicRoutesで、認証されていない人でも見れるページを指定します
  // 今回の場合はホームページだけログインしてなくても見れるようにしています
  publicRoutes: ["/"],
});

export const config = {
  matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
};

より複雑な設定にしたい場合は下記を参考にして設定できると思います!
https://clerk.com/docs/nextjs/middleware

Sign in ページの作成

サインインページを作っていきます。
ログイン画面の UI は Clerk の方で既に用意されたものを使うことで簡単に構築できます。
app 直下にsign-inフォルダーを、その配下に[[...sign-in]]フォルダーを作成します。

この[[...sign-in]]というフォルダー名の書き方はOptional Catch-all Segmentsというもので/sign-in/xxxx/sign-in/yyyy/zzzzだけでなく/sign-inにもマッチして、ページをキャッチしてくれるものです。
https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes#optional-catch-all-segments

[[...sign-in]]フォルダは以下にpage.tsxファイルを作成します。
ファイルの中身を以下のようにします。

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 です!
Clerkのサインイン画面

試しにログインしてみてください。ログインに成功したらホームページに飛ぶはずです。

他のライブラリとかだと、ログイン方法として Gogle や GitHub などの外部プロバイダと連携するにはプロバイダ先に行って別の設定が必要になったりする時がありますが、Clerk の場合は特に何もせずに利用可能なので楽でいいですね。
サインイン画面の時に出すアプリのロゴやカラーなどはダッシュボードのCustomizationから変更可能です。また細かいデザインは Tailwind CSS などを使ってappearanceプロパティから調整可能なようです!
https://clerk.com/docs/nextjs/appearance-prop

ヘッダーの作成

今の状態だと画面が寂しいのでヘッダーを作っていきます。
app 直下に_componentsフォルダーを作成します。この_アンダーバーはルーティングの対象外にしたいファイルに対して書く時に使います。アンダーバーがあるフォルダは以下全てがルーティング対象外となります。
https://nextjs.org/docs/app/building-your-application/routing/colocation#private-folders

_componentsフォルダーの配下にlayoutフォルダー、その配下にHeader.tsxファイルを作成します。
ファイルの中身は以下のようにします。

_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 に追加します。

/app/layout.tsx
...
+ 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 />になります。
https://clerk.com/docs/users/user-profile

このプロフィールのアカウントページにボタンを追加できればいいのですが、調べた感じだとボタンの追加はできなさそうです。(それ用のプロパティやダッシュボードからの設定などもないので無理そうでした)

そのため、今モーダルと表示しているアカウントページを/accountにアクセスすることで見れるようにして、そこにアカウント削除ボタンを追加することにします。

まず、app 直下にaccountフォルダーを作成し、その配下にいつも通りpage.tsxファイルを作成します。

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ファイルに以下を追記します。

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ファイルを追加します。
ファイルの中身は以下のようにします。

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を使用します。
https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions#custom-invocation-using-starttransition
この startTransition の中にサーバー側で実行したい処理を書きます。

Server Actions をまとめるファイルを作成し、そこにdeleteUser関数を書くことにしましょう。
app 直下に_libフォルダーを作成し、その配下にaction.tsファイルを作成します。
ファイルの中身を以下のようにします。

/_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 じゃないとダメだよとエラーになります。
deleteUserDeleteUserButton.tsxでインポートして、コメントアウトを消します。

また、isPendingを使用して、Server Action を実行中の場合はボタンの表示をなくし、「アカウント削除中...」を表示しています。
アカウント削除中だけの表示だと見た目が寂しいのでアニメーションを使います。
/app/_components配下にuiフォルダーを作成し、その配下にAnimation.tsxファイルを作成します。
ファイルの中身を以下のようにします。

/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 />を返すようにします。

/app/loading.tsx
import { ThreePointsAnimation } from "./_components/ui/Animation";

export default function Loading() {
  return <ThreePointsAnimation />;
}

次に、/account/page.tsxに戻り以下のように書き換えます。

/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を修正します。

/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 の導入

  1. PlanetScale の導入
    DB として今回は PlanetScale を使用します。この DB を使って、チャットルームやチャットの登録を行います。
    PlanetScalen の導入は以下の記事を参考に、「Database に接続する準備をする」まで終わらせてください。

https://zenn.dev/nbr41to/articles/adabca83b2e6ea#planetscaleでデータベースを作成する

  1. Prisma の導入
    今回は ORM として Prisma を使います。
    以下のコマンドを実行し、Prisma CLI のインストールとライブラリの追加を行います。
cmd
npm install prisma --save-dev
npm install @prisma/client

次に、prisma の初期化をし、schema ファイルの生成を行います。

cmd
npx prisma init

プロジェクト直下に/prisma/schema.prisma.envファイルが作成されたと思います。
.envファイルを.gitignoreファイルに追加するのを忘れないようにやっておきます。

.gitignore
# local env files
.env
.env*.local

次に、PlanetScale の Overview 画面にある Get Connection strings ボタンをクリックして、.envタブの中身をそのままコピーして.envファイルに貼り付けます。また、schema.prismaファイルも同様に貼り付けます。

Prisma でスキーマの定義

schema.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 にプッシュするため、以下のコマンドを実行します。

cmd
npx prisma db push

PlanetScale 上で以下のようなスキーマが確認できれば連携 OK です!
PlanetScaleにあるスキーマ

実際の DB 操作は Prisma Studio で行います。
以下のコマンドを実行し、ローカル上に Prisma Studio を立ち上げましょう。

cmd
npx prisma studio

Room テーブルに 2 つデータを追加しておきます。
Roomテーブルを手動で追加

これでひとまず Prisma + PlanetScale の導入準備は完了しました!

ホームの飾り付けをする

現状、ホームが「こんにちは」の文字しかない寂しい状態であるため飾り付けをしていきます。

ユーザーの名前とプロフィール画像の取得

Clerk で保持しているユーザー情報から名前とプロフィール画像を取得して表示させます。

/app/page.tsxを以下のように変更します。

/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を定義していきます。

/_lib/clerk.ts
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を以下のように変更します。

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ファイルを以下のように変更します。

/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ファイルを作成します。
そして、ファイルの中身を以下のようにします。

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ファイルを作成し、以下のように書きます。

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ファイルを作成し、そこに中身を書いていきます。

これは各ルームが持っているチャット数を表示するコンポーネントです。

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を作成します。

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ファイルを作成します。

/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 />コンポーネントに渡している引数としてuuidusernameprofileImageUrlがありますが、なぜUser型の user をそのまま渡さずに分けているかというと、  Server Component から Client Component(今回の場合は Form)にプロパティを渡すときは、JSON 形式にパースできる文字列でないとコンソールに warning が発生するからです。user オブジェクトをそのまま渡したいときは以下のようにプラグインを使ってシリアライズするしかなさそうです。
https://zenn.dev/sev3e3e/articles/43f566d940c807

この id を使って、ルームを取得する関数getRoomByIdとルーム作成日をフォーマットする関数formatDate、チャットを取得する関数getChats、User テーブルの id からユーザーを取得する関数getUserById/_lib/prisma.tsファイルに以下のように追加します。

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.tsxForm.tsxファイルを作成します。

Chats.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は以下のようにします。

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 関数の実行を行っています。
upsertUserActioncreateChatAction関数をaction.tsファイルに定義していきます。

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で使われている createUsercreateChat`関数を追加します。

prisma.ts
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 はupdateinsertの両方から取ったもので、User テーブルにすでにデータが存在している場合は更新を、存在していない場合は新規作成を行なってくれるものです。

ホームにあるルーム一覧から適当なルームに飛んでみると以下のような表示になっていると思います。
各ルームページの表示

この状態でチャットを送信してみると、何も表示が変わらないのでどこかがおかしいです。
この理由は、Server Actions でサーバー側の処理を行った時に、再検証がされておらずキャッシュが残った状態になっており、クライアント側の表示が変わっていないからです。

そのため、再検証(revalidate)を行うために、revalidatePath関数を action.ts のcreateChatAction関数の最後に追記します。

action.ts
+import { revalidatePath } from "next/cache";
...
export const createChatAction = async (
  roomId: number,
  userId: number,
  message: string
) => {
  await createChat(roomId, userId, message);
+  revalidatePath(`/room/${roomId}`);
};

revalidatePathは以下で詳しい説明が見れます。
https://nextjs.org/docs/app/api-reference/functions/revalidatePath

これで、再度チャットを送信してみると再検証が行われてチャットが表示されるはずです。
チャット送信後の表示

ルーム作成ページの作成

いよいよ疲れてきちゃったのですが、あと一息で終わります。
次はルーム作成のページを作ります。

/app/room配下にcreateフォルダーを、その配下にpage.tsxを作成します。

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ファイルに追記します。

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ファイルを作成します。

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 と同じかどうかでデザインを変えています。
https://nextjs.org/docs/app/api-reference/functions/use-pathname

この<LinkList />コンポーネントをHeader.tsxに追加します。

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ファイルに以下を追記します。

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 するなど何らかの対応がされればいいなと思います。
https://github.com/vercel/next.js/issues/42991

最後に、この記事を通して、「ここ間違ってるよ!」や「もっとこうしたほうがいい」みたいなことがあればコメントしてくださると幸いです!

GitHubで編集を提案

Discussion

yusuke kokuboyusuke kokubo

とても参考になる記事をありがとうございます!

自分の環境で写経してみたところ、2点ばかり修正が必要でした。

最初のセットアップ
npm install @clerk/clerk-js -> npm install @clerk/nextjs

action.tsupsertUserAction を追加するところでreturn忘れ
s/await createUser(...)/return await createUser(...)

やじはむやじはむ

報告してくださりありがとうございます!

修正いたしました!助かります🙇