🐼

Next.js(13/AppRouter)+Prisma(MySQL)+PandaCSSでTODOアプリを作る

2023/09/09に公開

今回は Next.js 13 の新機能 App Router をキャッチアップしたかったので、ついでに色々試してみました。
当方 pages ルーターでの実装や Prisma の経験は何度かあるので、その前提で記載します。

準備

検証時の環境は以下です。

$ node -v
v18.16.1
$ npm -v
9.5.1
$ yarn -v
1.22.19

またサンプルリポジトリは以下にあります。

https://github.com/tokku5552/nextjs-gcp-sample

Next.js のプロジェクト作成

まずは Next.js のプロジェクトを作成します。
2023/9/9 現在の最新版のcreate-next-appでは以下のような質問でした。

npx create-next-app@latest
✔ What is your project named? … my-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias? … No / Yes
✔ What import alias would you like configured? … @/*

基本デフォルトですが、Tailwind CSS は No(Panda CSS 使いたかったので)、import aliasは Yes を選択しています。(好み)

Prisma 導入

Prisma はTypeScript(Node.js)でおそらく最もスタンダードな ORM パッケージです。
https://www.prisma.io/

schema.prismaというファイルにスキーマを定義すると自動で Type Safe なクライアントコードが生成されます。

以下のコマンドでパッケージを追加します。

yarn add -D prisma
yarn prisma init

package.json に以下のスクリプトを追加しておきます。

my-app/package.json
...
  "scripts": {
    "dev": "rm -rf .next && next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "export": "next build && next export",
+   "db:migrate": "prisma migrate dev",
+   "db:migrate:reset": "prisma migrate reset",
+   "db:seed": "prisma db seed",
+   "db:studio": "prisma studio"
  },
...

ついでに、prisma initをしたときに生成されるprismaというフォルダをsrcの下に移動させておきます。
prisma の cli に場所を教えてあげるために、package.json に以下の記載を追加しておきます。

my-app/package.json
  },
+ "prisma": {
+   "schema": "src/prisma/schema.prisma",
+ }
}

MySQL の Docker 環境構築

Prisma はデフォルトで PostgreSQL の設定がされていますが、今回は MySQL を使います。
まず、ローカル DB として、以下のcompose.ymlでコンテナを用意しておきます。

compose.yml
version: '3.9'

services:
  db:
    image: mysql:latest
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: mysql
      MYSQL_DATABASE: db
      MYSQL_USER: user
      MYSQL_PASSWORD: password
    restart: always
    volumes:
      - mysql:/var/lib/mysql
volumes:
  mysql:

コンテナを起動します。

docker compose up -d

スキーマファイルを以下のように変更します。

schema.prisma
datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

.envファイルに接続設定がされていると思いますが、MySQL のものに書き換えます。
先程のcompose.ymlの定義の場合は、以下のようにしてやると動くと思います。

.env
DATABASE_URL="mysql://root:mysql@localhost:3306/db"

PandaCSS 導入

今回は最近話題(?)の Panda CSS を使ってみることにしました。

https://panda-css.com/

Panda CSS は簡単に言うと、ゼロランタイムで型安全で RSC(React Server Component)に互換性のある CSS in JS ライブラリです。
Emotion や styled-components のようなランタイム CSS in JS が RSC では使えないため、create-next-appの際は、単なるCSSフレームワークであるTailwind CSS がデフォルトで選択されます。
後発であり、Chakra UI や Tailwind CSS の良いところが受け継がれているようなライブラリになっています。(Chakra UI のチームが開発しています。)

以下のコマンドでパッケージを追加して初期化します。

yarn add -D @pandacss/dev
yarn panda init --postcss

styled-systemというフォルダが生成されると思いますが、これが次回以降自動生成されるように package.json にスクリプトを追加しておきます。

...
  "scripts": {
+   "prepare": "panda codegen",
    "dev": "rm -rf .next && next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "export": "next build && next export",
    "db:migrate": "prisma migrate dev",
    "db:migrate:reset": "prisma migrate reset",
    "db:seed": "prisma db seed",
    "db:studio": "prisma studio"
  },
...

.gitignore に以下を追加して、

# panda CSS
styled-system

path alias にも追加しておきます。

tsconfig.json
{
  "compilerOptions": {
   ...
    "paths": {
      "@/*": ["./src/*"],
+     "@/styled-system/*": ["./styled-system/*"]
    }
  },
...

最後にglobal.css を以下のように書き換えておきます。

my-app/src/app/global.css
@layer reset, base, tokens, recipes, utilities;

参考

https://zenn.dev/a_da_chi/articles/725ba2cd4ce358

以上で準備としては完了です。
サンプルのプロジェクトではswrreact-hook-formも使っているので、以下でインストールしました。

yarn add swr react-hook-form

アプリケーションコードの解説

アプリケーションの環境構築は終わったので実際に記述していきます。

Prisma を使って型定義する

まずはスキーマ定義します。
技術キャッチアップするときに作るアプリは簡単な Todo アプリと決めている(謎)ので、今回も Todo アプリでいきます。
簡単に Todo モデルだけ定義しておきました。

my-app/src/prisma/schema.prisma
model Todo{
  id Int @id @default(autoincrement())
  title String
  description String
  status Boolean @default(false)
}

以下のコマンドで DB を作成し、マイグレーションを行います。

yarn db:generate
yarn db:migration

migrationsフォルダにマイグレーションファイルが作成され、マイグレーションが実行されます。

アプリケーションで使うためのクライアントも定義しておきます。

yarn add @prisma/client
my-app/src/prisma/index.ts
import { PrismaClient } from "@prisma/client";
export * from "@prisma/client";

export const prisma = new PrismaClient({
  log: ["query", "info", "warn"],
});

こうしておくことで、prismaを ORM として使うことができます。

データフェッチする例
// todosは自動的にTodo[]の型がついている
const todos = await prisma.todo.findMany();

PandaCSS でのスタイリング

Panda CSS でのスタイリングは今回そこまでちゃんと使えていませんが、例えば以下のように記述することができます。

import { css } from "@/styled-system/css";

export default function Home() {
   return (
    <div
        className={css({
            fontSize: "2xl",
            fontWeight: "bold",
            textAlign: "center",
        })}
    >
    Todo App
    </div>
   )
}

自動生成されるstyled-systemというフォルダにcss が export されていますので、それを使って css の class を直接定義できます。
型補完が効くので思っているよりもコーディングは楽です。

その他にstyled-system/patternsの中にいくつかよく使うパターンが入っていて、これを使うと記述量が減らせて便利そうです。

https://panda-css.com/docs/concepts/patterns

App Router を使う

Next.js 13 では App Router という機能が追加され、13.4 で stable となりました。
追加されてから暫く経つので、詳細な説明は公式ページなどを参考にしていただいたり、他の記事などを調べていただくのが良いと思います。

ディレクトリベースのルーティングである点は pages ルーターと同じですが、ファイル名に特別な意味を持つようになりました。

最初から定義されているファイルでいうと、layout.tsxに共有のレイアウトを記載して、page.tsxに実際のページを記載します。
その他、pages ルーターでは api を作成する場合はpages/apiの下にファイルを置いていましたが、App Router ではroute.tsというファイルを置くことで api を作成できるようになりました。
詳細は以下で確認してください。

https://nextjs.org/docs/app/building-your-application/routing#file-conventions

また App Router になって大きく変わった点が RSC(React Server Components) がデフォルトであるということです。
React Server Components とは文字通り、サーバー側でコンポーネントの生成を行うものです。
useStateuseEffectなどの再描画系の処理は RSC では書けないのと、Emotion に依存している UI ライブラリも使えないため注意が必要です。

例えばサンプルアプリでは以下のコンポーネントが RSC です。

my-app/src/app/todo/[id]/page.tsx
import { prisma } from "@/prisma";
import Link from "next/link";

const getTodo = async (id: string) => {
  const todo = await prisma.todo.findUnique({ where: { id: parseInt(id) } });
  return todo;
};

export default async function Todo({ params }: { params: { id: string } }) {
  const { id } = params;
  const todo = await getTodo(id);
  return (
    <>
      <h1>Todo detail</h1>
      <p>id: {id}</p>
      <p>title: {todo?.title}</p>
      <p>description: {todo?.description}</p>
      <Link href="/">戻る</Link>
    </>
  );
}

getServerSidePropsではない場所で prisma のコードが実行できています。(NEXT_PUBLIC_でない環境変数がちゃんと読めている。)

またクライアントコンポーネントを定義したいときはtsxファイル内に、use clientと書きます。

my-app/src/components/TodoForm.tsx
"use client";

import { useGetTodos } from "@/hooks/useGetTodos";
import { css } from "@/styled-system/css";
import { hstack } from "@/styled-system/patterns";
import { useForm } from "react-hook-form";

export default function TodoForm() {
  const { handleSubmit, register, reset } = useForm<{ title: string }>();
  const { mutate } = useGetTodos();

  return (
    <form
      onSubmit={handleSubmit(async (value) => {
        const res = await fetch("/api/todo", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(value),
        });
        console.log(res);
        reset();
        mutate();
      })}
    >
      <div className={hstack({})}>
        <input
          type="text"
          className={css({
            height: "40px",
            width: "100%",
            padding: "8px",
            borderRadius: "4px",
            border: "none",
            boxShadow: "0 0 0 1px rgba(0,0,0,0.1)",
          })}
          placeholder="Enter your todo"
          {...register("title")}
        />
        <button
          type="submit"
          className={css({
            display: "flex",
            borderWidth: "1px",
            borderColor: "gray",
            color: "gray",
            padding: "8px",
            fontSize: "12px",
          })}
        >
          Add
        </button>
      </div>
    </form>
  );
}

これを RSC から呼ぶ事もできます。

my-app/src/app/page.tsx
import TodoForm from "@/components/TodoForm";
import TodoList from "@/components/TodoList";

import { css } from "@/styled-system/css";
import { vstack } from "@/styled-system/patterns";

export default function Home() {
  return (
    <main>
      <div
        className={css({
          fontSize: "2xl",
          fontWeight: "bold",
          textAlign: "center",
        })}
      >
        Todo App
      </div>

      <div className={vstack({ padding: "8" })}>
        <TodoForm /> {/* Client Component */}
        <TodoList /> {/* Client Component */}
      </div>
    </main>
  );
}

App Router で API を作る

API は Route Handler というものを使うことになります。

https://nextjs.org/docs/app/building-your-application/routing/route-handlers

GETPOSTなどの名前で関数を定義してやれば、それがディレクトリベースのルーティングに沿ってエンドポイントとして作成されます。

my-app/src/app/api/todo/[id]/route.ts
import { prisma } from "@/prisma";
import { NextResponse } from "next/server";

export async function GET(_: Request, { params }: { params: { id: string } }) {
  const id = Number(params.id);
  const todo = await prisma.todo.findUnique({ where: { id } });
  return NextResponse.json({ todo });
}

以下のように GET,POST など、複数の method に対して同時に定義することもできます。

my-app/src/app/api/todo/route.ts
import { prisma } from "@/prisma";
import { NextResponse } from "next/server";

export const dynamic = "force-dynamic";

export async function GET(_: Request) {
  const todos = await prisma.todo.findMany();
  return NextResponse.json({ todos });
}

export async function POST(request: Request) {
  const body = await request.json();
  const todo = await prisma.todo.create({
    data: {
      title: body.title,
      description: "",
      status: false,
    },
  });
  return NextResponse.json({ todo });
}

途中export const dynamic = "force-dynamic";という記述がありますが、検証時点ではPOSTなどでrequest.json()を呼ぶとそんなメソッドは無いというエラーが出てしまったので、この設定を記載しています。

https://nextjs.org/docs/app/building-your-application/routing/route-handlers#segment-config-options

所感

主に App Router のキャッチアップのためにこの技術スタックにしましたが、Panda CSS は情報も少なく(ChatGPT もまだ知らないらしい 🥹)、少し攻めすぎていて本番での採用は見送りかなぁと感じました 😭
最近話題のT3 StackではTailwind CSStRPCNextAuthを使うようなので、これらを使った App Router での実装も試してみたいところです 👀

Discussion