💧

Drizzle ORM + Next.js 14(Server Actions)でTodoアプリ作ってみた

2023/11/29に公開

はじめに

Drizzle ORMの導入検証のためTodoアプリ作ってみたので共有します。
DrizzleとServer Actionsの組み合わせでCRUDするとどんな感じなのか、使用感を確かめます。

前提

各バージョンは次の通りです。

  • Node.js 20.10.0
  • Next.js 14.0.3
  • Drizzle ORM 0.29.0

今回作成したアプリの挙動は以下の感じです。

DBはPostgreSQLを使用しており、Dockerで起動しています。
また、入力値検証やエラーハンドリングは省略していますので、そのあたりはご了承ください。

作ってみる

早速作ってみましょう。

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

create-next-appコマンドでプロジェクトを作成します。

terminal
npx create-next-app@latest

プロンプトはすべてデフォルト値で作成しています。

Drizzleインストール

Drizzleに関連するパッケージをインストールします。

terminal
npm i drizzle-orm postgres
npm i -D drizzle-kit

PostgreSQL以外にもMySQLやSQLiteに対応しています。
使用できるドライバは公式ドキュメントで確認できます。
https://orm.drizzle.team/docs/quick-mysql
https://orm.drizzle.team/docs/quick-sqlite

Migrations(テーブル作成)

テーブルを作成するため、以下の設定ファイルを作成します。

<project root>/
├─ db/
│    └─ schema.ts
├─ .env.local
├─ package.json
├─ drizzle.config.ts
└─ tsconfig.json

schema.tsの作成

dbフォルダを作成し、schema.tsファイルを追加します。todosテーブルを定義します。

db/schema.ts
import { boolean, pgTable, serial, varchar } from "drizzle-orm/pg-core";

export const todos = pgTable("todos", {
  id: serial("id").primaryKey(),
  todoName: varchar("todoName", { length: 256 }).notNull(),
  done: boolean("done").notNull().default(false),
});

drizzle.config.tsの作成

ルート直下にdrizzle.config.tsファイルを追加します。マイグレーション実行時にこの設定ファイルが使用されます。
loadEnvConfigで環境変数を読み込んでいます。環境変数process.env.DB_URLは後程定義します。
schemaでスキーマファイルのパスを指定しています。outにマイグレーションに必要なファイル(DDLなど)が出力されます。

drizzle.config.ts
import type { Config } from "drizzle-kit";
import { loadEnvConfig } from "@next/env";

loadEnvConfig(process.cwd());

export default {
  schema: "./db/schema.ts",
  out: "./drizzle",
  driver: "pg",
  dbCredentials: {
    connectionString: process.env.DB_URL!,
  },
} satisfies Config;

.env.localの作成

ルート直下に.env.localファイルを追加します。ご自身のDB環境に合わせて接続情報を設定してください。
私の場合DB_URL=postgres://admin:admin@localhost:5433/postgresのような感じになりました。

.env.local
DB_URL=postgres://[DBユーザ名]:[DBパスワード]@[ホスト名]:[ポート番号]/[DB名]

package.jsonにコマンド追加

package.jsonを修正します。generateコマンドとpushコマンドを追加します。

package.json
(略)
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
+   "db:generate": "drizzle-kit generate:pg",
+   "db:push": "drizzle-kit push:pg"
  },
(略)

tsconfig.jsonの修正

tsconfig.jsonのtargetをes2022に修正します。

tsconfig.json
{
  "compilerOptions": {
-   "target": "es5",
+   "target": "es2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
(略)

ここを修正しておかないと後の手順でgenerateコマンドを実行した際、以下のエラーとなります。

このあたりはバージョンにより挙動が異なるかもしれません。

コマンド実行

generateコマンドを実行し、マイグレーションファイルを作成します。
成功すればdrizzleフォルダにDDL等のファイルが生成されるはずです。

terminal
npm run db:generate

続けてpushコマンドを実行し、テーブルを作成します。PostgreSQLを立ち上げておいてください。

terminal
npm run db:push

todosテーブルが作成されます。

Insert(Todo登録)

テーブルが作成できたので、次は以下のファイルを作成し、Todo登録処理を作ります。

<project root>/
├─ app/
│    ├─ action-addTodo.ts
│    ├─ component-addTodo.tsx
│    ├─ globals.css
│    └─ page.tsx
└─ db/
     ├─ db.ts
     └─ schema.ts

db.tsの作成

db.tsファイルを追加します。データアクセス用オブジェクトを定義しておきます。
以降のSelect、Update、Deleteでも共通して使用します。

db/db.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

const queryClient = postgres(process.env.DB_URL!);
export const db = drizzle(queryClient);

schema.tsの修正

$inferInsertで型を定義できます。schema.tsに型も定義しておきます。

db/schema.ts
  import { boolean, pgTable, serial, varchar } from "drizzle-orm/pg-core";

  export const todos = pgTable("todos", {
    id: serial("id").primaryKey(),
    todoName: varchar("todoName", { length: 256 }).notNull(),
    done: boolean("done").notNull().default(false),
  });

+ export type InsTodo = typeof todos.$inferInsert;

action-addTodo.tsの作成

use serverでサーバ処理を作っていきます。
DBへのインサートはawait db.insert(todos).values(newTodo)の箇所です。

app/action-addTodo.ts
"use server";

import { db } from "@/db/db";
import { InsTodo, todos } from "@/db/schema";
import { revalidatePath } from "next/cache";

export async function addTodoAction(formData: FormData) {
  const todoStr = formData.get("todo") as string;

  const newTodo: InsTodo = {
    todoName: todoStr,
  };

  await db.insert(todos).values(newTodo);

  revalidatePath("/");
}

component-addTodo.tsxの作成

formを作っていきます。use clientを使用してクライアントコンポーネントとして作成しました。
先ほど作成したaddTodoActionを呼び出します。

app/component-addTodo.tsx
"use client";

import { addTodoAction } from "./action-addTodo";

export function AddTodoForm() {
  return (
    <form action={addTodoAction} className="flex flex-col">
      <input
        type="text"
        id="todo"
        name="todo"
        className="border border-gray-600 m-1 rounded"
        autoFocus
      />
      <button type="submit" className="bg-blue-500 text-white rounded m-1">
        Add Todo
      </button>
    </form>
  );
}

page.tsxの修正

作成したコンポーネントを表示します。

app/page.tsx
import { AddTodoForm } from "./component-addTodo";

export default async function Home() {
  return (
    <div className="max-w-xs">
      <AddTodoForm />
    </div>
  );
}

globals.cssの修正

見た目を整えます。tailwindだけ残し、他を削除します。

app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

ここまでで、Todo登録のformが完成しました。

Select(Todo一覧)

次は以下のファイルを作成し、Todo一覧の表示処理を作ります。登録したTodoを表示できるようになります。

<project root>/
├─ app/
│    ├─ component-listTodo.tsx
│    └─ page.tsx
└─ db/
     └─ schema.ts

schema.tsの修正

$inferSelectで型を定義できます。

db/schema.ts
  import { boolean, pgTable, serial, varchar } from "drizzle-orm/pg-core";

  export const todos = pgTable("todos", {
    id: serial("id").primaryKey(),
    todoName: varchar("todoName", { length: 256 }).notNull(),
    done: boolean("done").notNull().default(false),
  });

  export type InsTodo = typeof todos.$inferInsert;
+ export type SelTodo = typeof todos.$inferSelect;

component-listTodo.tsxの作成

サーバコンポーネントでは直接DBにアクセスできます。
Selectしている箇所はawait db.select().from(todos).orderBy(todos.id)です。

app/component-listTodo.tsx
import { db } from "@/db/db";
import { SelTodo, todos } from "@/db/schema";

export async function ListTodo() {
  const results = await db.select().from(todos).orderBy(todos.id);

  return (
    <div className="m-1">
      {results.map((todo: SelTodo) => {
        return (
          <div key={todo.id} className="border rounded p-1 flex shadow">
            <div className={`flex-1 ${todo.done ? "line-through" : ""}`}>
              {todo.todoName}
            </div>
          </div>
        );
      })}
    </div>
  );
}

page.tsxの修正

作成したコンポーネントを表示します。

app/page.tsx
  import { AddTodoForm } from "./component-addTodo";
+ import { ListTodo } from "./component-listTodo";

  export default async function Home() {
    return (
      <div className="max-w-xs">
        <AddTodoForm />
+       <ListTodo />
      </div>
    );
  }

ここまでで、Todo一覧が完成しました。

Update(Todo更新)

次は以下のファイルを作成し、Todoを完了に更新する処理を作成します。

<project root>/
└─ app/
     ├─ action-doneTodo.ts
     ├─ component-doneTodo.tsx
     └─ component-listTodo.tsx

action-doneTodo.tsの作成

更新用のサーバ処理を作成します。
Updateはdb.update(todos).set({ done: done }).where(eq(todos.id, id))の箇所です。

app/action-doneTodo.ts
"use server";

import { db } from "@/db/db";
import { todos } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";

export async function doneTodoAction(id: number, done: boolean) {
  await db.update(todos).set({ done: done }).where(eq(todos.id, id));
  revalidatePath("/");
}

component-doneTodo.tsxの作成

先ほど作成したdoneTodoActionを呼び出します。
formのactionから呼び出す方法ではなく、inputのonChangeイベントから呼び出してみます。

app/component-doneTodo.tsx
"use client";

import { useRef } from "react";
import { doneTodoAction } from "./action-doneTodo";

export function DoneTodoForm({ id, done }: { id: number; done: boolean }) {
  const inputRef = useRef<HTMLInputElement>(null);

  const handleChange = async () => {
    const doneChecked = inputRef.current?.checked ?? false;
    await doneTodoAction(id, doneChecked);
  };

  return (
    <>
      <input
        ref={inputRef}
        type="checkbox"
        name="done"
        defaultChecked={done}
        onChange={handleChange}
      />
    </>
  );
}

component-listTodo.tsxの修正

作成したコンポーネントをリストに表示します。

app/component-listTodo.tsx
  import { db } from "@/db/db";
  import { SelTodo, todos } from "@/db/schema";
+ import { DoneTodoForm } from "./component-doneTodo";

  export async function ListTodo() {
    const results = await db.select().from(todos).orderBy(todos.id);

    return (
      <div className="m-1">
        {results.map((todo: SelTodo) => {
          return (
            <div key={todo.id} className="border rounded p-1 flex shadow">
+             <DoneTodoForm id={todo.id} done={todo.done} />
              <div className={`flex-1 ${todo.done ? "line-through" : ""}`}>
                {todo.todoName}
              </div>
            </div>
          );
        })}
      </div>
    );
  }

Delete(Todo削除)

次は以下のファイルを作成し、Todoを削除する処理を作成します。

<project root>/
└─ app/
     ├─ action-delTodo.ts
     ├─ component-delTodo.tsx
     └─ component-listTodo.tsx

action-delTodo.tsの作成

削除用のサーバ処理を作成します。
Deleteはdb.delete(todos).where(eq(todos.id, id))の箇所です。

app/action-delTodo.ts
"use server";

import { db } from "@/db/db";
import { todos } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";

export async function deleteTodoAction(id: number) {
  await db.delete(todos).where(eq(todos.id, id));
  revalidatePath("/");
}

component-delTodo.tsxの作成

先ほど作成したdeleteTodoActionを呼び出します。
今度はbuttonのformActionから呼び出してみます。bindを使用することでformに存在しないパラメータも設定することができます。ここではTodoのidをbindしています。

app/component-delTodo.tsx
"use client";

import { deleteTodoAction } from "./action-delTodo";

export function DelTodoForm({ id }: { id: number }) {
  const bindDelTodoAction = deleteTodoAction.bind(null, id);

  return (
    <form>
      <button
        type="submit"
        formAction={bindDelTodoAction}
        className="bg-gray-300 rounded"
      >
        Delete
      </button>
    </form>
  );
}

component-listTodo.tsxの修正

最後に作成したコンポーネントをリストに表示して終わりです。

app/component-listTodo.tsx
  import { db } from "@/db/db";
  import { SelTodo, todos } from "@/db/schema";
  import { DoneTodoForm } from "./component-doneTodo";
+ import { DelTodoForm } from "./component-delTodo";

  export async function ListTodo() {
    const results = await db.select().from(todos).orderBy(todos.id);

    return (
      <div className="m-1">
        {results.map((todo: SelTodo) => {
          return (
            <div key={todo.id} className="border rounded p-1 flex shadow">
              <DoneTodoForm id={todo.id} done={todo.done} />
              <div className={`flex-1 ${todo.done ? "line-through" : ""}`}>
                {todo.todoName}
              </div>
+             <DelTodoForm id={todo.id} />
            </div>
          );
        })}
      </div>
    );
  }

まとめ

前から気になっていたDrizzle ORM + Next.jsの組み合わせで、簡単なアプリを作ってみました。
また、DrizzleでのCRUDに加え、Server Actionsの呼び出し方法もいくつか試してみました。Server Actionsを使用したエラーハンドリングや認証周りをもう少し勉強して使いこなせるようになりたいです。

バックエンドを準備せず、Next.jsからDBへ直接クエリを発行できるのは新たな開発体験でした。バックエンドAPIのコード量削減、リソース削減(バックエンドサーバ立ち上げなくていい)は大きなメリットかなと思います。個人開発や小規模開発のようなプロジェクトの立ち上げスピードが求められる場面では重宝するのではないでしょうか?

ほぼ個人的メモのようなソース掲載となっていますが、誰かの参考になれば幸いです。

Discussion