Next.jsでPrismaからSupabaseを操作してみる

投稿日:

更新日:

はじめに

Prismaを使用してSupabaseを操作する簡易的なToDoリストをNext.jsで構築してみます。

主に使用するものは以下になります。

  • Next.js v14.2.5(App Router)
  • Prisma v5.18.0
  • Prisma Client v5.18.0
  • Supabase
  • TypeScript v5
  • shadcn/ui(Tailwind CSS v3.4.1)

環境構築〜マイグレーション

  1. 以下のコマンドを実行しプロジェクトを作成します。ページのデザインを整えるためにshadcn/uiを追加でインストールしています。
    ターミナル
    npx create-next-app@latest my-app --typescript --tailwind --eslint
    npx shadcn-ui@latest init
  2. コマンド実行後の質問には以下のように回答します。
    ターミナル
    # Next.js
    ✔ 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
    
    # shadcn/ui
    ✔ Which style would you like to use? › Default
    ✔ Which color would you like to use as base color? › Slate
    ✔ Would you like to use CSS variables for colors? … yes
  3. Supabaseのダッシュボードにアクセスし、[New Project]を押し任意の組織(Organization)を選択します。
    Supabaseの新規プロジェクト作成ボタン
    プロジェクト作成時、該当する組織名を選択する
  4. プロジェクト名とパスワードを設定し、[Create new project]を押下
    Supabaseの新規プロジェクト設定画面
    プロジェクト名、パスワード、地域を設定する
  5. 次に以下のコマンドを実行してPrismaをインストール&初期化します。
    ターミナル
    npm install prisma --save-dev
    npx prisma init
  6. Prismaからデータベースを操作するので、Prisma Clientも合わせてインストールします。
    ターミナル
    npm install @prisma/client
  7. Supabaseで[Project Settings] > [Database]の[Connection String]を開き[URI]を選択し、記載されているURIをコピーして、接続モード別に.envDATABASE_URLDIRECT_URLに設定してください。
    SupabaseのDatabase SettingsのConnection Stringのキャプチャ
    Connection StringでURIを選択する。Modeを切り替えて環境変数にそれぞれのURIを設定する。
    .env
    DATABASE_URL="" # Transaction接続プーラーのURIを設定します。
    DIRECT_URL="" # Session接続プーラーのURIを設定します。
  8. prisma/schema.prismaを開き、必要なモデルを定義します。
    schema.prisma
    generator client {
      provider = "prisma-client-js"
    }
    
    datasource db {
      provider  = "postgresql"
      url       = env("DATABASE_URL")
      directUrl = env("DIRECT_URL")
    }
    
    // 以下の記述を追加してください。
    model Task {
      id        String  @id @default(cuid())
      title     String
      completed Boolean @default(false)
    
      createdAt DateTime @default(now()) @map("created_at")
      updatedAt DateTime @updatedAt @map("updated_at")
    
      @@map("task")
    }
    
  9. 以下のコマンドを実行し、データベースマイグレーションを作成します。
    ターミナル
    npx prisma migrate dev --name init
    • --name initでマイグレーションに「init」という名前をつけています。
  10. 実行が完了すると、ほぼリアルタイムでSupabaseのTable Editorに追加したテーブルが反映されていることが確認できます。
    SupabaseのTable Editorにschema.prismaで設定したモデルが反映されている
    SupabaseのTable Editorにschema.prismaで設定したモデルが反映されている

APIの作成

シングルトンパターンを導入し、アプリケーション全体で単一のPrismaClientインスタンスを使用するようにします。

  1. 以下のコマンドを実行して、Prisma Clientの生成をおこないます。
    ターミナル
    npx prisma generate
  2. src/libprisma.tsを作成し、以下のコードを追加しシングルトンパターンを実現します。
    prisma.ts
    import { PrismaClient } from '@prisma/client'
    
    const prismaClientSingleton = () => {
      return new PrismaClient()
    }
    
    declare const globalThis: {
      prismaGlobal: ReturnType<typeof prismaClientSingleton>;
    } & typeof global;
    
    const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
    
    export default prisma
    
    if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
    

    Prismaに従い以下の記述を使用します。

  3. src/app/api/tasksroute.tsを作成し、以下のコードを追加します。
    route.ts
    import prisma from "@/lib/prisma";
    import { type NextRequest, NextResponse } from "next/server";
    
    export async function GET(req: NextRequest) {
      try {
        const tasks = await prisma.task.findMany();
        return NextResponse.json(tasks);
      } catch (error) {
        console.error("Request error", error);
        return NextResponse.json({ error: "Error fetching tasks" }, { status: 500 });
      }
    }
    
    export async function POST(req: NextRequest) {
      try {
        const body = await req.json();
        const newTask = await prisma.task.create({
          data: {
            title: body.title,
          },
        });
        return NextResponse.json(newTask, { status: 201 });
      } catch (error) {
        console.error("Request error", error);
        return NextResponse.json({ error: "Error creating tasks" }, { status: 500 });
      }
    }
    
  4. src/app/api/tasks/[id]route.tsを作成し、以下のコードを追加します。
    route.ts
    import { type NextRequest, NextResponse } from "next/server";
    import prisma from "@/lib/prisma";
    
    export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
      try {
        const id = params.id;
        const { completed } = await req.json();
    
        const updatedTask = await prisma.task.update({
          where: { id },
          data: { completed },
        });
    
        return NextResponse.json(updatedTask);
      } catch (error) {
        console.error("Error updating task:", error);
        return NextResponse.json({ error: "Failed to update task" }, { status: 500 });
      }
    }
    
    export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
      try {
        const id = params.id;
    
        await prisma.task.delete({
          where: { id },
        });
    
        return NextResponse.json({ message: "Task deleted successfully" });
      } catch (error) {
        console.error("Error deleting task:", error);
        return NextResponse.json({ error: "Failed to delete task" }, { status: 500 });
      }
    }
    

ページの作成と実装

  1. src/componentsAddTask.tsxを作成し、以下のコードを追加します。
    AddTask.tsx
    "use client";
    import { Task } from "@prisma/client";
    import React, { useState } from "react";
    import { Button } from "@/components/ui/button";
    import { Input } from "@/components/ui/input";
    
    interface Props {
      onAdd: (title: Task["title"]) => void;
    }
    
    export default function AddTask({ onAdd }: Props) {
      const [title, setTitle] = useState<Task["title"]>("");
    
      /**
       * 新しいタスクを追加する
       *
       * @param e - フォーム送信イベント
       */
      const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        if (title.trim()) {
          onAdd(title);
          setTitle("");
        }
      };
    
      return (
        <form onSubmit={handleSubmit} className="flex space-x-2">
          <Input
            type="text"
            value={title}
            onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)}
            placeholder="Add a new task"
            className="flex-grow"
          />
          <Button type="submit">Add</Button>
        </form>
      );
    }
    
  2. src/componentsTaskItem.tsxを作成し、以下のコードを追加します。
    TaskItem.tsx
    import { Task } from "@prisma/client";
    import React from "react";
    import { Checkbox } from "@/components/ui/checkbox";
    import { Button } from "@/components/ui/button";
    
    interface Props {
      task: Task;
      onToggle: (id: Task["id"]) => void;
      onDelete: (id: Task["id"]) => void;
    }
    
    export default function TaskItem({ task, onToggle, onDelete }: Props) {
      return (
        <div className="flex items-center justify-between py-2">
          <div className="flex items-center space-x-2">
            <Checkbox checked={task.completed} onCheckedChange={() => onToggle(task.id)} />
            <span className={task.completed ? "line-through" : ""}>{task.title}</span>
          </div>
          <Button variant="destructive" size="sm" onClick={() => onDelete(task.id)}>
            Delete
          </Button>
        </div>
      );
    }
    
  3. src/componentsTaskList.tsxを作成し、以下のコードを追加します。
    TaskList.tsx
    "use client";
    import { Task } from "@prisma/client";
    import React, { useEffect, useState } from "react";
    import AddTask from "@/components/AddTask";
    import TaskItem from "@/components/TaskItem";
    import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
    
    export default function TaskList() {
      const [tasks, setTasks] = useState<Task[]>([]);
    
      useEffect(() => {
        fetchTasks();
      }, []);
    
      /**
       * タスク一覧を取得する
       *
       * @throws {Error} タスクの追加に失敗した場合
       */
      const fetchTasks = async () => {
        try {
          const res = await fetch("/api/tasks");
          const data: Task[] = await res.json();
          setTasks(data);
        } catch (error) {
          console.error("タスクの取得に失敗しました:", error);
        }
      };
    
      /**
       * 新しいタスクを追加する
       *
       * @param title - 新しいタスクのタイトル
       * @throws {Error} タスクの追加に失敗した場合
       */
      const addTask = async (title: Task["title"]) => {
        try {
          const res = await fetch("/api/tasks", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ title }),
          });
          const newTask: Task = await res.json();
          setTasks([...tasks, newTask]);
        } catch (error) {
          console.error("タスクの追加に失敗しました:", error);
        }
      };
    
      /**
       * タスクの完了状態を切り替える
       *
       * @param id - 更新するタスクのID
       * @throws {Error} タスクの更新に失敗した場合
       */
      const toggleTask = async (id: Task["id"]) => {
        try {
          const task = tasks.find((task) => task.id === id);
          if (!task) return;
    
          const res = await fetch(`/api/tasks/${id}`, {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ completed: !task.completed }),
          });
          if (res.ok) {
            setTasks(tasks.map((task) => (task.id === id ? { ...task, completed: !task.completed } : task)));
          }
        } catch (error) {
          console.error("タスクの切り替えに失敗しました:", error);
        }
      };
    
      /**
       * タスクを削除する
       *
       * @param id - 削除するタスクのID
       * @throws {Error} タスクの削除に失敗した場合
       */
      const deleteTask = async (id: Task["id"]) => {
        try {
          const res = await fetch(`/api/tasks/${id}`, { method: "DELETE" });
          if (res.ok) {
            setTasks(tasks.filter((task) => task.id !== id));
          }
        } catch (error) {
          console.error("タスクの削除に失敗しました:", error);
        }
      };
    
      return (
        <Card className="w-full max-w-md mx-auto mt-10">
          <CardHeader>
            <CardTitle>ToDo List</CardTitle>
          </CardHeader>
          <CardContent>
            <AddTask onAdd={addTask} />
            <div className="mt-4 space-y-2">
              {tasks.map((task) => (
                <TaskItem key={task.id} task={task} onToggle={toggleTask} onDelete={deleteTask} />
              ))}
            </div>
          </CardContent>
        </Card>
      );
    }
    
  4. 最後にsrc/app/page.tsxにTaskListコンポーネントを追加します。
    page.tsx
    import TaskList from "@/components/TaskList";
    
    export default function Home() {
      return (
        <div>
          <TaskList />
        </div>
      );
    }
    

動作確認

以下のToDo Listが作成できました。

完成したToDoリストのページキャプチャ
完成したToDoリストのページキャプチャ
  • タスクを追加

    タスクを追加するとフォームの下に入力したタスクが追加されます。

    ToDoリストに「散歩をする」タスクを追加したキャプチャ
    タスクを追加した状態

    Supabaseにもタスクが追加されていることが確認できます。

    Supabaseに「散歩をする」タスクが追加されているキャプチャ
    Supabaseにもタスクが追加されている
  • タスクを完了

    タスク名の横のチェックボックスにチェックを入れるとタスクが完了となり、取り消し線が表示されます。

    ToDoリストに「散歩をする」タスクを完了にしたキャプチャ
    タスクを完了した状態

    Supabaseを見ると対象のタスクのcompletedの値がTRUEになっていることが確認できます。

    Supabaseの「散歩をする」タスクのcompletedがTRUEになっているキャプチャ
    completedがTRUEになっている

  • タスクを削除

    タスク名の右側のDeleteボタンを押すとタスクが削除され、リストから無くなります。

    Supabaseを見ると対象のタスクが削除されていることが確認できます。

  • 複数タスクの管理

    最後にタスクを複数追加し、より実際の利用に近い形で検証をおこないます。

    ToDoリストに複数のタスクが登録され、そのうち2つが完了となっている
    複数のタスクを登録したToDoリスト

    Supabaseも念の為確認しておきます。

    複数のタスクが登録されているSupabaseのキャプチャ
    複数のタスクが登録されている

    一通り確認できたらこれでPrismaとSupabaseを使用したNext.jsのアプリケーション作成が完了になります。

schema.prismaのコードフォーマット

Visual Studio Codeで自動フォーマットをするために、settings.jsonに以下の記述を追加してください。

settings.json
{
  "[prisma]": {
    "editor.defaultFormatter": "Prisma.prisma"
  }
}

合わせて以下の拡張機能も追加してください。

参考・引用

この記事をシェアする