📝

Next.js(Server Actions) + Prisma + Supabase で 簡易的なTodo アプリを作成する

2024/05/30に公開

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 ディレクディブ以外のデフォルトのカスタムプロパティやスタイルは不要なため削除しておきます。

global.css
@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ファイルを作成し、設定を追加します。

src/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ファイルが自動で作成されます。

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
.env
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 のプロジェクト作成時に設定したパスワードを指定します。

.env
postgres://postgres.******************:*********@********************.supabase.com:5432/postgres

モデルの定義

先ほど自動生成されたprisma/shema.prismaにモデルを定義します。
今回は、Todo のタイトル、ID、投稿時間からなる Todo モデルを作成します。

prisma/shema.prisma
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 データを投入します。

prisma/seed.ts
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 実行用のコマンドを登録します。

package.json
"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で呼び出します。

src/app/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 データを取得し表示しています。

src/components/TodoList.tsx
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で呼び出します。

src/app/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>
   );
}
src/components/CreateTodoForm.tsx
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 関数を渡します。

src/components/CreateTodoForm.tsx
+ 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 関数を移動します。

action.ts
'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('/')
}
src/components/CreateTodoForm.tsx
- 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で呼び出します。

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>
    );
  };

DeleteTodoButon.tsx
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 関数を追加します。

action.ts
export const deleteTodo = async (id: number) => {
  await prisma.todo.delete({
    where: { id },
  });
  revalidatePath("/");
};

Todo を削除するには、id が必要になるため、deleteTodo 関数では引数として id を受け取るように設定しています。
また、 Todo 削除後は即時に反映したいので、キャッシュをパージするために revalidatePath を指定しています。

次に、TodoList.tsx を 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 />
+            <DeleteTodoButton id={todo.id} />
           </div>
         );
       })}
    </div>
   );
 };

TodoList.tsx では、DeleteTodoButton で Todo の id が必要になるため、id を props として渡すようにしました。

DeleteTodoButton.tsx
  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 アプリは完成となります。

参考

https://www.prisma.io/docs/orm/prisma-migrate/workflows/seeding
https://zenn.dev/moozaru/articles/c3bfd1a7e3c004
https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices

Discussion