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)
環境構築〜マイグレーション
- 以下のコマンドを実行しプロジェクトを作成します。ページのデザインを整えるためにshadcn/uiを追加でインストールしています。ターミナル
npx create-next-app@latest my-app --typescript --tailwind --eslint npx shadcn-ui@latest init
- コマンド実行後の質問には以下のように回答します。ターミナル
# 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
- Supabaseのダッシュボードにアクセスし、[New Project]を押し任意の組織(Organization)を選択します。
- プロジェクト名とパスワードを設定し、[Create new project]を押下
- 次に以下のコマンドを実行してPrismaをインストール&初期化します。ターミナル
npm install prisma --save-dev npx prisma init
- Prismaからデータベースを操作するので、Prisma Clientも合わせてインストールします。ターミナル
npm install @prisma/client
- Supabaseで[Project Settings] > [Database]の[Connection String]を開き[URI]を選択し、記載されているURIをコピーして、接続モード別に
.env
のDATABASE_URL
とDIRECT_URL
に設定してください。.envDATABASE_URL="" # Transaction接続プーラーのURIを設定します。 DIRECT_URL="" # Session接続プーラーのURIを設定します。
prisma/schema.prisma
を開き、必要なモデルを定義します。schema.prismagenerator 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") }
- 以下のコマンドを実行し、データベースマイグレーションを作成します。ターミナル
npx prisma migrate dev --name init
--name init
でマイグレーションに「init」という名前をつけています。
- 実行が完了すると、ほぼリアルタイムでSupabaseのTable Editorに追加したテーブルが反映されていることが確認できます。
APIの作成
シングルトンパターンを導入し、アプリケーション全体で単一のPrismaClientインスタンスを使用するようにします。
- 以下のコマンドを実行して、Prisma Clientの生成をおこないます。ターミナル
npx prisma generate
src/lib
にprisma.ts
を作成し、以下のコードを追加しシングルトンパターンを実現します。prisma.tsimport { 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に従い以下の記述を使用します。
src/app/api/tasks
にroute.ts
を作成し、以下のコードを追加します。route.tsimport 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 }); } }
src/app/api/tasks/[id]
にroute.ts
を作成し、以下のコードを追加します。route.tsimport { 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 }); } }
ページの作成と実装
src/components
にAddTask.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> ); }
src/components
にTaskItem.tsx
を作成し、以下のコードを追加します。TaskItem.tsximport { 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> ); }
src/components
にTaskList.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> ); }
- 最後に
src/app/page.tsx
にTaskListコンポーネントを追加します。page.tsximport TaskList from "@/components/TaskList"; export default function Home() { return ( <div> <TaskList /> </div> ); }
動作確認
以下のToDo Listが作成できました。
- タスクを追加
タスクを追加するとフォームの下に入力したタスクが追加されます。
Supabaseにもタスクが追加されていることが確認できます。
- タスクを完了
タスク名の横のチェックボックスにチェックを入れるとタスクが完了となり、取り消し線が表示されます。
Supabaseを見ると対象のタスクの
completed
の値がTRUE
になっていることが確認できます。 - タスクを削除
タスク名の右側のDeleteボタンを押すとタスクが削除され、リストから無くなります。
Supabaseを見ると対象のタスクが削除されていることが確認できます。
- 複数タスクの管理
最後にタスクを複数追加し、より実際の利用に近い形で検証をおこないます。
Supabaseも念の為確認しておきます。
一通り確認できたらこれでPrismaとSupabaseを使用したNext.jsのアプリケーション作成が完了になります。
schema.prismaのコードフォーマット
Visual Studio Codeで自動フォーマットをするために、settings.json
に以下の記述を追加してください。
settings.json
{
"[prisma]": {
"editor.defaultFormatter": "Prisma.prisma"
}
}
合わせて以下の拡張機能も追加してください。