Drizzle ORM + Next.js 14(Server Actions)でTodoアプリ作ってみた
はじめに
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
コマンドでプロジェクトを作成します。
npx create-next-app@latest
プロンプトはすべてデフォルト値で作成しています。
Drizzleインストール
Drizzleに関連するパッケージをインストールします。
npm i drizzle-orm postgres
npm i -D drizzle-kit
PostgreSQL以外にもMySQLやSQLiteに対応しています。
使用できるドライバは公式ドキュメントで確認できます。
Migrations(テーブル作成)
テーブルを作成するため、以下の設定ファイルを作成します。
<project root>/
├─ db/
│ └─ schema.ts
├─ .env.local
├─ package.json
├─ drizzle.config.ts
└─ tsconfig.json
schema.tsの作成
dbフォルダを作成し、schema.ts
ファイルを追加します。todosテーブルを定義します。
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など)が出力されます。
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
のような感じになりました。
DB_URL=postgres://[DBユーザ名]:[DBパスワード]@[ホスト名]:[ポート番号]/[DB名]
package.jsonにコマンド追加
package.json
を修正します。generateコマンドとpushコマンドを追加します。
(略)
"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
に修正します。
{
"compilerOptions": {
- "target": "es5",
+ "target": "es2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
(略)
ここを修正しておかないと後の手順でgenerate
コマンドを実行した際、以下のエラーとなります。
このあたりはバージョンにより挙動が異なるかもしれません。
コマンド実行
generate
コマンドを実行し、マイグレーションファイルを作成します。
成功すればdrizzle
フォルダにDDL等のファイルが生成されるはずです。
npm run db:generate
続けてpush
コマンドを実行し、テーブルを作成します。PostgreSQLを立ち上げておいてください。
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でも共通して使用します。
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
に型も定義しておきます。
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)
の箇所です。
"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を呼び出します。
"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の修正
作成したコンポーネントを表示します。
import { AddTodoForm } from "./component-addTodo";
export default async function Home() {
return (
<div className="max-w-xs">
<AddTodoForm />
</div>
);
}
globals.cssの修正
見た目を整えます。tailwindだけ残し、他を削除します。
@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
で型を定義できます。
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)
です。
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の修正
作成したコンポーネントを表示します。
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))
の箇所です。
"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
イベントから呼び出してみます。
"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の修正
作成したコンポーネントをリストに表示します。
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))
の箇所です。
"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
しています。
"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の修正
最後に作成したコンポーネントをリストに表示して終わりです。
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