Next.js(Server Actions) + Prisma + Supabase で 簡易的なTodo アプリを作成する
Next.js の Server Actions を学習として、簡単な Todo アプリを作成したので、その過程を記事にまとめておきます。
作成した Todo アプリは、追加と削除の機能のみを備えた大変シンプルなものです。完成イメージは以下の通りです。
この Todo アプリではServer Actions を利用した CRUD 操作を簡単に実現するために、 ORM に Prisma を、BaaS に Supabase を利用しました。
本記事で触れないこと
- Server Actions の詳細
- Server Actions のフォームバリデーション
- Prisma と Supabase の詳細
環境構築
Next.js プロジェクトの作成
create-next-app
コマンドを実行して、Next.js のプロジェクトを作成します。
npx create-next-app@latest
コマンド実行時の質問に対しては以下のように回答しました。
What is your project named? › todo-app
Would you like to use TypeScript? › Yes
Would you like to use ESLint? › Yes
Would you like to use Tailwind CSS? › Yes
Would you like to use `src/` directory? › Yes
Would you like to use App Router? (recommended) › Yes
Would you like to customize the default import alias (@/*)? › No
global.css の編集
Tailwind ディレクディブ以外のデフォルトのカスタムプロパティやスタイルは不要なため削除しておきます。
@tailwind base;
@tailwind components;
@tailwind utilities;
Supabaseのセットアップ
Supabase のプロジェクト一覧画面から、「New Project」ボタンを押し、プロジェクトを作成します。
プロジェクト作成には Name, Database Password, Region をそれぞれ設定する必要があります。
プロジェクト作成時に設定する Database Password は後で必要になるので記録しておいて下さい。
Prisma のセットアップ
prisma とprisma-client をインストールします。
npm install prisma --save-dev
npm install @prisma/client
Prisma Client の設定
Prisma Client の設定を行います。Next.js から Prisma を使用してデータベースにアクセスする際の設定となります。
lib
ディレクトリを作成し、その中にprisma.ts
ファイルを作成し、設定を追加します。
import { PrismaClient } from '@prisma/client';
let prisma: PrismaClient;
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient();
} else {
if (!global.prisma) {
global.prisma = new PrismaClient();
}
prisma = global.prisma;
}
export default prisma;
初期化
続いて以下のコマンドを実行し、Prisma の初期化を行います。
npx prisma init
初期化を行うと、prisma/schema.prisma
ファイルと.env
ファイルが自動で作成されます。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
.env
ファイルが作成されましたが、DATABASE_URL
の接続先を Supabase のものに変更する必要があります。
Supabase の Database Settings から 接続用の URI を取得する必要があります。
この URI をコピーし、.env
ファイルのDATABASE_URL
に指定します。
コピーしてきた URI のなかに [YOUR-PASSWORD]とありますが、ここに Supabase のプロジェクト作成時に設定したパスワードを指定します。
postgres://postgres.******************:*********@********************.supabase.com:5432/postgres
モデルの定義
先ほど自動生成されたprisma/shema.prisma
にモデルを定義します。
今回は、Todo のタイトル、ID、投稿時間からなる Todo モデルを作成します。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
+ model Todo {
+ id Int @id @default(autoincrement())
+ title String
+ created_at DateTime @default(now())
+ }
IDと投稿時間に関しては、自動で記録されるよう定義しておきます。
マイグレーション
マイグレーションを行います。
npx prisma migrate dev --name init
マイグレーションを行うと、Supabase の Database Tables に Todo テーブルが作成されます。
初期データの投入
Todo アプリを作成するにあたり、初期データが存在する方が都合がいいので、初期データを投入しておきます。
方法は色々あると思いますが、今回は Prisma の Seed 機能を利用して、Todo テーブルの初期データを投入します。
まずは、Seed 実行に必要なts-node
と@types/node
をインストールします。
npm install -D ts-node @types/node
続いて、prisma
フォルダ内に seed.ts
ファイルを作成し、初期データを投入するためのロジックを定義します。今回は3つの Todo データを投入します。
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function seed() {
const todos = [{title: 'Learn Next.js'}, {title: 'Learn React 19'}, {title: "Learn Typescript"}]
for (let todo of todos) {
await prisma.todo.create({
data: todo
})
}
}
seed()
.catch(e => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
package.json
ファイルに seed 実行用のコマンドを登録します。
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}
これで、初期データ投入の準備は完了です。
以下のコマンドを実行し、初期データを投入しましょう。
npx prisma db seed
実行できたら、Supabase の Todo テーブルを確認します。
初期データが登録されていれば、データ投入は完了です。
以上で環境構築は完了となります。
Todo アプリを作成する
環境構築が終わったので、追加と削除が可能な Todo アプリを作成していきたいと思います。
Todo 一覧表示の実装
まずは、Todo 一覧を表示したいと思います。
TodoList.tsx
を作成し、page.tsx
で呼び出します。
import { TodoList } from "@/components/TodoList";
export default function Home() {
return (
<div className="max-w-xl mx-auto">
<h1 className="text-center text-3xl font-bold mb-3">TODO APP</h1>
<div className="bg-amber-100 p-5 rounded-lg">
<TodoList />
</div>
</div>
);
}
TodoList コンポーネントでは、Prisma を利用して、全ての Todo データを取得し表示しています。
import prisma from "@/lib/prisma";
export const TodoList = async () => {
// 全ての Todo データを取得
const todos = await prisma.todo.findMany();
return (
<div className="space-y-5">
{todos.map((todo) => {
return (
<div
key={todo.id}
className="flex justify-between items-center p-3 bg-white rounded-lg"
>
{todo.title}
</div>
);
})}
</div>
);
};
Todo の一覧を表示することができました。
Todo 追加フォームの実装
次に Todo 追加フォームを作成していきたいと思います。
ひとまず、フォームと追加ボタンの UI を表示します。
CreateTodoForm.tsx
を作成し、page.tsx
で呼び出します。
import { CreateTodoForm } from "@/components/CreateTodoForm";
import { TodoList } from "@/components/TodoList";
export default function Home() {
return (
<div className="max-w-xl mx-auto">
<h1 className="text-center text-3xl font-bold mb-3">TODO APP</h1>
+ <div className="mb-6">
+ <CreateTodoForm />
+ </div>
<div className="bg-amber-100 p-5 rounded-lg">
<TodoList />
</div>
</div>
);
}
export const CreateTodoForm = () => {
return (
<form>
<div className="flex items-center justify-center space-x-3">
<input
required
placeholder="TODO を入力"
type="text"
className="max-w-sm bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded-lg focus:border-blue-500 block w-full p-2 cursor-not-allowed"
defaultValue=""
/>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg cursor-pointer hover:bg-blue-800 focus:ring-4 focus:ring-blue-300"
>
追加
</button>
</div>
</form>
);
};
Todo 追加フォームと追加ボタンを表示することができました。
ただ現状 UIのみの表示しかしておらず、データを登録するための処理を実装していないため、追加ボタンを押しても Todo は追加できません。
なので、Todo を追加できるように実装していきます。追加機能を実装の実装には Next.js の Server Actions を利用します。
Server Actions を利用するため、addTodo 関数を作成を作成し、form タグの action 属性に addTodo 関数を渡します。
+ import prisma from "@/lib/prisma";
+ import { revalidatePath } from "next/cache"
export const CreateTodoForm = () => {
+ const addTodo = async (formData: FormData) => {
+ 'use server'
+ const title = formData.get('title') as string
+ await prisma.todo.create({data: {title}})
+ revalidatePath('/')
+ }
return (
- <form>
+ <form action={addTodo}>
<div className="flex items-center justify-center space-x-3">
<input
placeholder="TODO を入力"
type="text"
className="max-w-sm bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded-lg focus:border-blue-500 block w-full p-2 cursor-not-allowed"
defaultValue=""
/>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg cursor-pointer hover:bg-blue-800 focus:ring-4 focus:ring-blue-300"
>
追加
</button>
</div>
</form>
);
};
form タグの action 属性に指定した関数は、自動的に FormData オブジェクトを受け取ることができます。(今回の場合、Todo のタイトルが FormData オブジェクトに含まれます)
addTodo 関数では、FormData オブジェクトで受け取った Todo のタイトルを、Prisma を利用して直接データベースに保存しています。
また、revalidatePath を利用して、追加した Todo がすぐに反映されるようにしています。
revalidatePath の引数にページのパスを指定すると、該当のページでキャッシュされたデータをパージし再検証することが可能になります。
では、挙動を確認してみます。
無事 Todo を追加することができました!
最後に、addTodo 関数を別ファイルに移動させたいと思います。
lib
ディレクトリに action.ts
ファイルを作成し、 その中に addTodo 関数を移動します。
'use server'
import { revalidatePath } from "next/cache"
import prisma from "./prisma"
export const addTodo = async (formData: FormData) => {
const title = formData.get('title') as string
await prisma.todo.create({data: {title}})
revalidatePath('/')
}
- import prisma from "@/lib/prisma";
- import { revalidatePath } from "next/cache"
export const CreateTodoForm = () => {
- const addTodo = async (formData: FormData) => {
- 'use server'
- const title = formData.get('title') as string
- await prisma.todo.create({data: {title}})
- revalidatePath('/')
- }
return (
<form action={addTodo}>
<div className="flex items-center justify-center space-x-3">
<input
placeholder="TODO を入力"
type="text"
className="max-w-sm bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded-lg focus:border-blue-500 block w-full p-2 cursor-not-allowed"
defaultValue=""
/>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg cursor-pointer hover:bg-blue-800 focus:ring-4 focus:ring-blue-300"
>
追加
</button>
</div>
</form>
);
};
削除ボタンの実装
不要な Todo を削除するための削除ボタンを実装していきます。
まずは、削除ボタンの表示を行います。
DeleteTodoButton.tsx
を作成し、TodoList.tsx
で呼び出します。
import prisma from "@/lib/prisma";
import { DeleteTodoButton } from "./DeleteTodoButton";
export const TodoList = async () => {
const todos = await prisma.todo.findMany();
return (
<div className="space-y-5">
{todos.map((todo) => {
return (
<div
key={todo.id}
className="flex justify-between items-center p-3 bg-white rounded-lg"
>
{todo.title}
+ <DeleteTodoButton />
</div>
);![](https://storage.googleapis.com/zenn-user-upload/85c2b2dced86-20240428.png)
})}
</div>
);
};
export const DeleteTodo = () => {
return (
<button className="px-3 py-1 text-sm font-medium text-white bg-rose-600 rounded-lg cursor-pointer hover:bg-red-700 focus:ring-4 focus:ring-rose-300">
削除
</button>
);
};
削除ボタンを表示することができました。
Server Actions を利用して、Todo を削除できるよう実装していきます。
action.ts
に 削除用の deleteTodo 関数を追加します。
export const deleteTodo = async (id: number) => {
await prisma.todo.delete({
where: { id },
});
revalidatePath("/");
};
Todo を削除するには、id が必要になるため、deleteTodo 関数では引数として id を受け取るように設定しています。
また、 Todo 削除後は即時に反映したいので、キャッシュをパージするために revalidatePath を指定しています。
次に、TodoList.tsx を DeleteTodoButton.tsx を編集していきます。
import prisma from "@/lib/prisma";
import { DeleteTodoButton } from "./DeleteTodoButton";
export const TodoList = async () => {
const todos = await prisma.todo.findMany();
return (
<div className="space-y-5">
{todos.map((todo) => {
return (
<div
key={todo.id}
className="flex justify-between items-center p-3 bg-white rounded-lg"
>
{todo.title}
- <DeleteTodoButton />
+ <DeleteTodoButton id={todo.id} />
</div>
);
})}
</div>
);
};
TodoList.tsx では、DeleteTodoButton で Todo の id が必要になるため、id を props として渡すようにしました。
import { deleteTodo } from "@/lib/action";
- export const DeleteTodoButton = () => {
+ export const DeleteTodoButton = ({id} : {id: number}) => {
+ const deleteTodoWithId = deleteTodo.bind(null, id)
return (
+ <form action={deleteTodoWithId}>
<button className="px-3 py-1 text-sm font-medium text-white bg-rose-600 rounded-lg cursor-pointer hover:bg-red-700 focus:ring-4 focus:ring-rose-300">
削除
</button>
+ </form>
);
};
Server Actions を利用するため、button タグを form タグでラップします。
また、Server Actions で追加の引数を渡すには、bind メソッドを利用する必要があります。
今回の場合は、id を deleteTodo 関数に渡したいので、deleteTodoWithId を作成しています。
作成した deleteTodoWithId を form タグの action 属性に渡しています。
これで、削除機能の実装が完了しました。
挙動を見てみます。
無事 Todo を削除することができました!
これで今回作成する Todo アプリは完成となります。
参考
Discussion