【Next.js】Prismaを使用したデータベースへの保存処理|簡単なToDoアプリの作成

Next.jsとPrismaを使い、ToDoアプリのデータをデータベースに保存する方法を初心者向けに解説します。これまでメモリ上に一時保存していたデータを、Prismaを導入して永続化する手順を学びます。APIでのデータの作成、読み取り、更新、削除(CRUD)といった基本的なデータベース操作を実装できるようになります。

作成日: 更新日:

開発環境

  • OS: Windows10
  • Visual Studio Code: 1.73.0
  • node: 22.14.0
  • react: 19.0.0
  • next: 15.3.2
  • tailwindcss: 4
  • Prisma: 6.8.2

サンプルコード

/lib/prisma.tsx

/lib/prisma.tsx
1+import { PrismaClient } from '@prisma/client'
2+
3+const globalForPrisma = globalThis as unknown as {
4+  prisma: PrismaClient | undefined
5+}
6+
7+export const prisma =
8+  globalForPrisma.prisma ??
9+  new PrismaClient({
10+    log: ['query'],
11+  })
12+
13+if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
14

/package.json

/package.json
1     "lint": "next lint"
2   },
3   "dependencies": {
4+    "@prisma/client": "^6.8.2",
5+    "next": "15.3.2",
6+    "prisma": "^6.8.2",
7     "react": "^19.0.0",
8-    "react-dom": "^19.0.0",
9-    "next": "15.3.2"
10+    "react-dom": "^19.0.0"
11   },
12   "devDependencies": {
13-    "typescript": "^5",
14+    "@eslint/eslintrc": "^3",
15+    "@tailwindcss/postcss": "^4",
16     "@types/node": "^20",
17     "@types/react": "^19",
18     "@types/react-dom": "^19",
19-    "@tailwindcss/postcss": "^4",
20-    "tailwindcss": "^4",
21     "eslint": "^9",
22     "eslint-config-next": "15.3.2",
23-    "@eslint/eslintrc": "^3"
24+    "tailwindcss": "^4",
25+    "typescript": "^5"
26   }
27-}
28+}
29

/prisma/migrations/20250517041143_init/migration.sql

/prisma/migrations/20250517041143_init/migration.sql
1+-- CreateTable
2+CREATE TABLE `Todo` (
3+    `id` INTEGER NOT NULL AUTO_INCREMENT,
4+    `text` VARCHAR(191) NOT NULL,
5+
6+    PRIMARY KEY (`id`)
7+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
8

/prisma/migrations/20250517041536_init/migration.sql

/prisma/migrations/20250517041536_init/migration.sql
1+/*
2+  Warnings:
3+
4+  - You are about to drop the `Todo` table. If the table is not empty, all the data it contains will be lost.
5+
6+*/
7+-- DropTable
8+DROP TABLE `Todo`;
9+
10+-- CreateTable
11+CREATE TABLE `todos` (
12+    `id` INTEGER NOT NULL AUTO_INCREMENT,
13+    `text` VARCHAR(191) NOT NULL,
14+
15+    PRIMARY KEY (`id`)
16+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
17

/prisma/schema.prisma

/prisma/schema.prisma
1+// This is your Prisma schema file,
2+// learn more about it in the docs: https://pris.ly/d/prisma-schema
3+
4+// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5+// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6+
7+generator client {
8+  provider = "prisma-client-js"
9+}
10+
11+datasource db {
12+  provider = "mysql"
13+  url      = env("DATABASE_URL")
14+}
15+
16+model Todo {
17+  id    Int    @id @default(autoincrement())
18+  text  String
19+
20+  @@map("todos")
21+}
22

/src/app/api/todos/route.tsx

/src/app/api/todos/route.tsx
1 import { NextResponse } from 'next/server'
2-
3-let todos: string[] = []
4+import { prisma } from '@lib/prisma'
5 
6 export async function GET() {
7+  const todos = await prisma.todo.findMany({
8+    orderBy: { id: 'desc' },
9+  })
10   return NextResponse.json(todos)
11 }
12 
13 export async function POST(req: Request) {
14-  const { todo } = await req.json()
15-  if (!todo || typeof todo !== 'string') {
16-    return NextResponse.json({ error: 'Invalid todo' }, { status: 400 })
17-  }
18-  todos.push(todo)
19-  return NextResponse.json(todos)
20+  const { text } = await req.json()
21+  const newTodo = await prisma.todo.create({
22+    data: { text },
23+  })
24+  return NextResponse.json(newTodo)
25 }
26 
27 export async function DELETE(req: Request) {
28-  const { index } = await req.json()
29-  if (typeof index !== 'number' || index < 0 || index >= todos.length) {
30-    return NextResponse.json({ error: 'Invalid index' }, { status: 400 })
31-  }
32-  todos.splice(index, 1)
33-  return NextResponse.json(todos)
34+  const { id } = await req.json()
35+  await prisma.todo.delete({
36+    where: { id },
37+  })
38+  return NextResponse.json({ message: 'Deleted' })
39 }
40 
41 export async function PATCH(req: Request) {
42-  const { index, newTodo } = await req.json()
43-  if (
44-    typeof index !== 'number' ||
45-    index < 0 ||
46-    index >= todos.length ||
47-    typeof newTodo !== 'string'
48-  ) {
49-    return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
50-  }
51-  todos[index] = newTodo
52-  return NextResponse.json(todos)
53+  const { id, text } = await req.json()
54+  await prisma.todo.update({
55+    where: { id },
56+    data: { text },
57+  })
58+  return NextResponse.json({ message: 'Updated' })
59 }
60

/src/app/page.tsx

/src/app/page.tsx
1 
2 import { useEffect, useState } from 'react'
3 
4+type Todo = {
5+  id: number
6+  text: string
7+}
8+
9 export default function Home() {
10-  const [todos, setTodos] = useState<string[]>([])
11+  const [todos, setTodos] = useState<Todo[]>([])
12   const [input, setInput] = useState('')
13-  const [editIndex, setEditIndex] = useState<number | null>(null)
14+  const [editId, setEditId] = useState<number | null>(null)
15   const [editText, setEditText] = useState('')
16 
17   const fetchTodos = async () => {
18     await fetch('/api/todos', {
19       method: 'POST',
20       headers: { 'Content-Type': 'application/json' },
21-      body: JSON.stringify({ todo: input }),
22+      body: JSON.stringify({ text: input }),
23     })
24     setInput('')
25     fetchTodos()
26   }
27 
28-  const handleDelete = async (index: number) => {
29+  const handleDelete = async (id: number) => {
30     await fetch('/api/todos', {
31       method: 'DELETE',
32       headers: { 'Content-Type': 'application/json' },
33-      body: JSON.stringify({ index }),
34+      body: JSON.stringify({ id }),
35     })
36     fetchTodos()
37   }
38 
39-  const handleEdit = (index: number) => {
40-    setEditIndex(index)
41-    setEditText(todos[index])
42+  const handleEdit = (todo: Todo) => {
43+    setEditId(todo.id)
44+    setEditText(todo.text)
45   }
46 
47   const handleUpdate = async () => {
48-    if (editIndex === null || !editText.trim()) return
49+    if (editId === null || !editText.trim()) return
50     await fetch('/api/todos', {
51       method: 'PATCH',
52       headers: { 'Content-Type': 'application/json' },
53-      body: JSON.stringify({ index: editIndex, newTodo: editText }),
54+      body: JSON.stringify({ id: editId, text: editText }),
55     })
56-    setEditIndex(null)
57+    setEditId(null)
58     setEditText('')
59     fetchTodos()
60   }
61 
62         {/* Todoリスト */}
63         <ul className="space-y-2">
64-          {todos.map((todo, index) => (
65+          {todos.map((todo) => (
66             <li
67-              key={index}
68+              key={todo.id}
69               className="flex items-center justify-between bg-gray-50 p-2 rounded"
70             >
71               <div className="flex-1">
72-                {editIndex === index ? (
73+                {editId === todo.id ? (
74                   <input
75                     type="text"
76                     value={editText}
77                     className="w-full border-b border-blue-400 px-1 focus:outline-none"
78                   />
79                 ) : (
80-                  <span>{todo}</span>
81+                  <span>{todo.text}</span>
82                 )}
83               </div>
84 
85               <div className="flex gap-2 ml-2">
86-                {editIndex === index ? (
87+                {editId === todo.id ? (
88                   <button
89                     onClick={handleUpdate}
90                     className="text-green-600 hover:underline text-sm"
91                   </button>
92                 ) : (
93                   <button
94-                    onClick={() => handleEdit(index)}
95+                    onClick={() => handleEdit(todo)}
96                     className="text-blue-600 hover:underline text-sm"
97                   >
98                     編集
99                   </button>
100                 )}
101-
102                 <button
103-                  onClick={() => handleDelete(index)}
104+                  onClick={() => handleDelete(todo.id)}
105                   className="text-red-500 hover:underline text-sm"
106                 >
107                   削除
108

/tsconfig.json

/tsconfig.json
1       }
2     ],
3     "paths": {
4-      "@/*": ["./src/*"]
5+      "@/*": ["./src/*"],
6+      "@lib/*": ["./lib/*"]
7     }
8   },
9   "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
10

コード解説

変更点: Prisma関連パッケージのインストール

/package.json
1   "dependencies": {
2+    "@prisma/client": "^6.8.2",
3     "next": "15.3.2",
4+    "prisma": "^6.8.2",
5     "react": "^19.0.0",
6     "react-dom": "^19.0.0"
7   },
8

ToDoアプリのデータをデータベースで管理するために、Prismaというツールをプロジェクトに導入します。この変更は、npm install prisma @prisma/client コマンドを実行した結果です。

  • prisma: データベースのテーブル構造を管理(マイグレーション)したり、データベースに接続するためのコマンドを提供してくれるパッケージです。
  • @prisma/client: 私たちが書くNext.jsのコードから、実際にデータベースのデータを読み書き(CRUD操作)するための機能を提供してくれるパッケージです。

変更点: Prismaスキーマの定義

/prisma/schema.prisma
1+generator client {
2+  provider = "prisma-client-js"
3+}
4+
5+datasource db {
6+  provider = "mysql"
7+  url      = env("DATABASE_URL")
8+}
9+
10+model Todo {
11+  id    Int    @id @default(autoincrement())
12+  text  String
13+
14+  @@map("todos")
15+}

プロジェクトのルートディレクトリに/prisma/schema.prismaという新しいファイルを作成しました。このファイルは、Prismaがデータベースとどのように連携するかを定義する設計図のようなものです。

  • datasource db: 接続するデータベースの種類(今回はmysql)と、接続情報(url)を指定しています。接続情報は、環境変数DATABASE_URLから読み込む設定です。
  • model Todo: データベースに作成するテーブルの構造を定義しています。Todoという名前のモデルを定義し、これが実際のデータベースではtodosという名前のテーブルに対応することを@@map("todos")で指定しています。
  • idtext: todosテーブルが持つカラム(列)を定義しています。idは整数型(Int)で、@idで主キー(データを一意に識別するためのキー)に設定し、@default(autoincrement())でデータが追加されるたびに自動で連番が振られるようにしています。textは文字列型(String)で、ToDoの内容を保存します。

変更点: データベースマイグレーションの実行

/prisma/migrations/20250517041536_init/migration.sql
1+-- CreateTable
2+CREATE TABLE `todos` (
3+    `id` INTEGER NOT NULL AUTO_INCREMENT,
4+    `text` VARCHAR(191) NOT NULL,
5+
6+    PRIMARY KEY (`id`)
7+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

schema.prismaファイルで定義したモデルを元に、実際のデータベースにテーブルを作成するためのSQLファイルが自動生成されました。これは、npx prisma migrate devというコマンドを実行することで行われます。 このSQL文は、schema.prismaで定義したTodoモデルに従って、idtextというカラムを持つtodosテーブルをデータベース上に作成します。このようにPrismaを使うことで、テーブル定義をコードで管理し、安全にデータベースの構造を変更(マイグレーション)することができます。

変更点: Prisma Clientのインスタンス化

/lib/prisma.tsx
1+import { PrismaClient } from '@prisma/client'
2+
3+const globalForPrisma = globalThis as unknown as {
4+  prisma: PrismaClient | undefined
5+}
6+
7+export const prisma =
8+  globalForPrisma.prisma ??
9+  new PrismaClient({
10+    log: ['query'],
11+  })
12+
13+if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

Next.jsアプリケーション全体でデータベース操作を行うためのPrismaClientのインスタンスを生成し、どこからでも使えるようにするファイルを作成しました。 開発環境では、ファイルを変更するたびにプログラムが再読み込みされ、その都度新しいPrismaClientインスタンスが作られてしまうと、データベースへの接続数が無駄に増えてしまいます。 このコードでは、globalThisという特殊なオブジェクトに生成したインスタンスを保存しておくことで、インスタンスが一つだけ存在するように(シングルトンパターン)工夫しています。これにより、効率的なデータベース接続を維持できます。

変更点: TypeScriptのパスエイリアス設定

/tsconfig.json
1     "paths": {
2-      "@/*": ["./src/*"]
3+      "@/*": ["./src/*"],
4+      "@lib/*": ["./lib/*"]
5     }
6   },

tsconfig.jsonファイルに、"@lib/*": ["./lib/*"]という設定を追加しました。これは「パスエイリアス」という機能で、コードの中でファイルをインポートする際のパスを短く書けるようにする設定です。 この設定により、例えばimport { prisma } from '../../lib/prisma'のような長い相対パスを書く代わりに、import { prisma } from '@lib/prisma'という分かりやすい絶対パスで、先ほど作成したPrisma Clientのインスタンスをインポートできるようになります。

変更点: API処理のデータベース連携

/src/app/api/todos/route.tsx
1-let todos: string[] = []
2+import { prisma } from '@lib/prisma'
3 
4 export async function GET() {
5+  const todos = await prisma.todo.findMany({
6+    orderBy: { id: 'desc' },
7+  })
8   return NextResponse.json(todos)
9 }
10 
11 export async function POST(req: Request) {
12-  const { todo } = await req.json()
13-  todos.push(todo)
14-  return NextResponse.json(todos)
15+  const { text } = await req.json()
16+  const newTodo = await prisma.todo.create({
17+    data: { text },
18+  })
19+  return NextResponse.json(newTodo)
20 }
21 
22 export async function DELETE(req: Request) {
23-  const { index } = await req.json()
24-  todos.splice(index, 1)
25-  return NextResponse.json(todos)
26+  const { id } = await req.json()
27+  await prisma.todo.delete({
28+    where: { id },
29+  })
30+  return NextResponse.json({ message: 'Deleted' })
31 }
32 
33 export async function PATCH(req: Request) {
34-  const { index, newTodo } = await req.json()
35-  todos[index] = newTodo
36-  return NextResponse.json(todos)
37+  const { id, text } = await req.json()
38+  await prisma.todo.update({
39+    where: { id },
40+    data: { text },
41+  })
42+  return NextResponse.json({ message: 'Updated' })
43 }

これまでメモリ上の配列(let todos: string[] = [])に一時的に保存していたToDoデータを、Prismaを通じてデータベースに永続的に保存するようにAPIの処理を全面的に書き換えました。

  • GET (取得): prisma.todo.findMany()を使い、データベースのtodosテーブルから全てのToDoを取得します。
  • POST (作成): prisma.todo.create()を使い、リクエストで受け取ったtexttodosテーブルに新しいデータとして保存します。
  • DELETE (削除): prisma.todo.delete()を使い、リクエストで受け取ったidに一致するToDoをテーブルから削除します。
  • PATCH (更新): prisma.todo.update()を使い、指定されたidのToDoのtextを新しい内容に更新します。

このように、Prismaが提供する直感的なメソッドを使うことで、簡単にデータベースのCRUD(作成、読み取り、更新、削除)操作を実装できます。

変更点: フロントエンドのデータ構造とAPI連携の修正

/src/app/page.tsx
1+type Todo = {
2+  id: number
3+  text: string
4+}
5+
6 export default function Home() {
7-  const [todos, setTodos] = useState<string[]>([])
8+  const [todos, setTodos] = useState<Todo[]>([])
9   const [input, setInput] = useState('')
10-  const [editIndex, setEditIndex] = useState<number | null>(null)
11+  const [editId, setEditId] = useState<number | null>(null)
12
13// ...
14
15-  const handleDelete = async (index: number) => {
16+  const handleDelete = async (id: number) => {
17     await fetch('/api/todos', {
18       method: 'DELETE',
19       headers: { 'Content-Type': 'application/json' },
20-      body: JSON.stringify({ index }),
21+      body: JSON.stringify({ id }),
22     })
23// ...
24-  const handleEdit = (index: number) => {
25-    setEditIndex(index)
26-    setEditText(todos[index])
27+  const handleEdit = (todo: Todo) => {
28+    setEditId(todo.id)
29+    setEditText(todo.text)
30   }
31// ...
32         <ul className="space-y-2">
33-          {todos.map((todo, index) => (
34+          {todos.map((todo) => (
35             <li
36-              key={index}
37+              key={todo.id}
38// ...
39-                {editIndex === index ? (
40+                {editId === todo.id ? (
41// ...

APIがデータベースと連携するようになったため、フロントエンド(画面側)のコードもそれに合わせて修正しました。主な変更点は以下の通りです。

  1. データ型の変更: ToDoデータを単なる文字列(string)ではなく、データベースの構造に合わせてidtextを持つオブジェクト{ id: number, text: string }として扱うようにTodo型を定義しました。Stateで管理するtodosの型もTodo[]に変更しています。
  2. IDによるデータ特定: これまでToDoの編集や削除を配列のインデックス番号(index)で行っていましたが、データベースで一意に決まるidを使うように変更しました。これにより、より安全で確実なデータ操作が可能になります。
  3. Reactのkeyプロパティの変更: リスト表示を行うmapメソッドで、各要素を識別するためのkeyプロパティに、indexの代わりに不変でユニークなtodo.idを指定しました。これはReactのパフォーマンスを最適化するためのベストプラクティスです。

おわりに

この記事では、Prismaを導入してToDoアプリのデータをデータベースに保存し、永続化する方法を学びました。schema.prismaでデータモデルを定義し、APIの処理をメモリ上の配列操作からprisma.todo.createといったデータベースを操作するメソッドへ書き換えました。フロントエンドも、配列のインデックス番号の代わりにデータベースのidで各ToDoを管理するように変更し、より確実なデータ操作を実現しました。これにより、ブラウザをリロードしてもデータが消えることのない、本格的なWebアプリケーションの基礎が完成しました。

関連コンテンツ

関連IT用語