🚀

Next.js入門: データベースとの連携を学びながらToDoリストを作ろう

2023/10/11に公開

この記事の対象読者

  • Next.js を学んだ次のステップとしてアプリ開発をしてみたい人
  • データベースを使用したアプリを作りたい人

はじめに

今回は Next.js を使ってデータベースとの連携を学びながら ToDo リストを作っていきます。
データベースも扱うため、ポートフォリオを作成する際に最低限必要な知識が身につくと思います。
この記事は Next.js のバージョン 13 で作成されています。もし Next.js のバージョン 13 について詳しく知らない場合は、以前に書いた関連記事を参考にしてみてください。
記事はこちらAppRouter に触れてみよう!

プロジェクトのセットアップ

まず、 Next.js をインストールします。

yarn create next-app

✔ What is your project named? … todo-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes

次に、データベースとの連携のために Prisma のセットアップを行います。

Prisma とは

Prisma は、データベースとのやり取りを簡単に行うためのツールです。
テーブルを作成したり、データを取得したり、更新したり、削除したりすることができます。
テーブルを元に型を作成してくれるので、型定義を書く手間が省けます。

yarn add prisma
npx prisma init

npx prisma initを実行すると、prismaディレクトリが作成さ、schema.prismaファイルが作成されます。
schema.prismaファイルにはデータベースの設定が記述されています。

schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

このファイルは後々編集します。

これでプロジェクトのセットアップは完了です。
データベースの設定の前にまずは見た目だけ作っていきましょう。

見た目の作成

src/pages/index.tsx
'use client'
import { useState } from 'react'

type Todo = {
  id: number
  title: string
  completed: boolean
}

const todos: Todo[] = [
  { id: 1, title: 'todo1', completed: false },
  { id: 2, title: 'todo2', completed: false },
  { id: 3, title: 'todo3', completed: true },
]

export default function Home() {
  const [inputValue, setInputValue] = useState<string | null>(null)

  return (
    <div className='container mx-auto p-4'>
      <h1 className='text-3xl font-bold mb-4'>Todo</h1>
      {todos.map((todo) => (
        <div
          key={todo.id}
          className='flex items-center justify-between bg-gray-200 p-2 rounded mb-2'
        >
          <div className='flex items-center'>
            <input
              type='checkbox'
              checked={todo.completed}
              onChange={() => console.log('completedを更新する処理')}
              className='mr-2'
            />
            <p className={`text-black ${todo.completed ? 'line-through' : ''}`}>
              {todo.title}
            </p>
          </div>
          <button
            onClick={() => console.log('削除する処理')}
            className='bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded'
          >
            削除
          </button>
        </div>
      ))}
      <form
        onSubmit={() => console.log('追加する処理')}
        className='flex items-center mt-4'
      >
        <input
          type='text'
          className='border border-gray-400 px-4 py-2 mr-2 rounded text-black'
          value={inputValue || ''}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder='Todoを入力してください'
        />
        <button
          type='submit'
          className='bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded'
        >
          追加
        </button>
      </form>
    </div>
  )
}

見た目はこんな感じです。

データベースの設定

これから、console.logとした箇所をデータベースとやりとりする処理に変更していきます。
そのために、まずはデータベースの設定を行いましょう。
今回は vercel のデータベースを使用します。
vercel の管理画面で Storage というタブがあるのでそこをクリックしてください。
画面右上の Create Database をクリックしてください。

Database Name には任意の名前を入力してください。
Region は日本から最も近い Singapore を選択します。

作成された画面はこちらです。

.env ファイルを作成して、環境変数をコピーしてください。
Prisma の設定も記載されているので、それもコピーします。

prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider  = "postgresql"
  url       = env("POSTGRES_URL")
  directUrl = env("POSTGRES_URL_NON_POOLING")
}

これでデータベースの設定は完了です。

テーブルの作成

今回は、ToDo リストを作成するので、todoテーブルを作成します。
schema.prisma ファイルに以下のように記述します。

prisma/schema.prisma
model Todo {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  title     String
  completed Boolean  @default(false)
}

@idは主キーを表します。
@default(autoincrement())は自動でインクリメントすることを表します。
completedにはデフォルトでfalseを設定しています。

モデルが作成できたら、npx prisma migrate dev --name initを実行して、データベースに反映させます。
以下のようなファイルが作成されます。

prisma/migrations/20231012103232_init/migration.sql
-- CreateTable
CREATE TABLE "Todo" (
    "id" SERIAL NOT NULL,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "title" TEXT NOT NULL,
    "completed" BOOLEAN NOT NULL DEFAULT false,

    CONSTRAINT "Todo_pkey" PRIMARY KEY ("id")
);

ディレクトリ名は、作成日時と--nameで指定した名前が組み合わさっています。

とりあえず仮データを入れておきましょう。
vercel の管理画面で Storage タブの Query で以下のように入力してください。

insert into "Todo" (title,completed) values ('掃除',false),('勉強',false),('買い物',true);

最後に、npx prisma generate を実行して、schema.prisma に対応する型を生成します。

npx prisma generate

これでテーブルの作成は完了です。

API の作成

取得(GET)、追加(POST)、更新(PATCH)、削除(DELETE)の処理を行う API を作成します。
エンドポイントは /api/todoとします。

取得

pages/api/todo/route.ts
import { PrismaClient, Todo } from '@prisma/client'

const prisma = new PrismaClient()

export async function GET() {
  // todoテーブルから全件取得
  const todos: Todo[] = await prisma.todo.findMany()
  return Response.json(todos)
}

Next.js バージョン 13 では、関数名とメソッドが対応しています。
今回は GET メソッドなので、関数名はGETとなります。

うまくいっているか確認のために、yarn devを実行して、http://localhost:3000/api/todoにアクセスしてみましょう。

取得されていることが確認できます。

追加

pages/api/todo/route.ts
export async function POST(request: Request) {
  const { title }: { title: string } = await request.json()
  // todoテーブルに追加
  const response = await prisma.todo.create({
    data: {
      title,
    },
  })
  return Response.json(response)
}

title以外はデフォルトのものを使用するので、titleのみを受け取ります。
今回は全件取得してきたデータをuseStateで管理し作成されたデータを追加するため、返しています。

更新

pages/api/todo/[id]/route.ts
import { PrismaClient } from '@prisma/client'
import { NextRequest } from 'next/server'

const prisma = new PrismaClient()

export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const id = Number(params.id)
  const { completed }: { completed: boolean } = await request.json()
  // リクエストのidを元にcompletedを反転させる
  const response = await prisma.todo.update({
    where: {
      id,
    },
    data: {
      completed: !completed,
    },
  })
  return Response.json(response)
}

更新、削除の処理は、対象となるデータの id を受け取る必要があるので、エンドポイントをapi/todo/[id]としています。

削除

pages/api/todo/[id]/route.ts
export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const id = Number(params.id)
  // リクエストのidを元に削除
  const response = await prisma.todo.delete({
    where: {
      id,
    },
  })
  return Response.json(response)
}

id を元に削除をしています。

フロントエンドの実装

API を作成したので、次にフロントエンドの実装を行います。
環境変数としてNEXT_PUBLIC_API_URLを作成しておきます。

NEXT_PUBLIC_API_URL='http://localhost:3000/api'

今回は扱いませんが、本番環境で利用するときは、この url を変更します。

src/pages/index.tsx
'use client'
import { Todo } from '@prisma/client'
import { useEffect, useState } from 'react'

export default function Home() {
  const [inputValue, setInputValue] = useState<string | null>(null)
  const [todos, setTodos] = useState<Todo[]>([])

  useEffect(() => {
    const getTodo = async () => {
      const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/todo`)
      const todos = await response.json()
      setTodos(todos)
    }
    getTodo()
  }, [])

  return (
    <div className='container mx-auto p-4'>
      <h1 className='text-3xl font-bold mb-4'>Todo</h1>
      {todos.map((todo) => (
        <div
          key={todo.id}
          className='flex items-center justify-between bg-gray-200 p-2 rounded mb-2'
        >
          <div className='flex items-center'>
            <input
              type='checkbox'
              checked={todo.completed}
              onChange={async () => {
                const response = await fetch(
                  `${process.env.NEXT_PUBLIC_API_URL}/todo/${todo.id}`,
                  {
                    method: 'PATCH',
                    headers: {
                      'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({ completed: todo.completed }),
                  }
                )
                const updateTodo = await response.json()
                setTodos(
                  todos.map((todo) => {
                    if (todo.id === updateTodo.id) {
                      return updateTodo
                    } else {
                      return todo
                    }
                  })
                )
              }}
              className='mr-2'
            />
            <p className={`text-black ${todo.completed ? 'line-through' : ''}`}>
              {todo.title}
            </p>
          </div>
          <button
            onClick={async (e) => {
              e.preventDefault()
              const response = await fetch(
                `${process.env.NEXT_PUBLIC_API_URL}/todo/${todo.id}`,
                {
                  method: 'DELETE',
                }
              )
              const deleteTodo = await response.json()
              setTodos(todos.filter((todo) => todo.id !== deleteTodo.id))
            }}
            className='bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded'
          >
            削除
          </button>
        </div>
      ))}
      <form
        onSubmit={async (e) => {
          e.preventDefault()
          if (!inputValue) alert('入力してください')
          const response = await fetch(
            `${process.env.NEXT_PUBLIC_API_URL}/todo`,
            {
              method: 'POST',
              headers: {
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({ title: inputValue }),
            }
          )
          const newTodo = await response.json()

          setTodos([...todos, newTodo])
          setInputValue(null)
        }}
        className='flex items-center mt-4'
      >
        <input
          type='text'
          className='border border-gray-400 px-4 py-2 mr-2 rounded text-black'
          value={inputValue || ''}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder='Todoを入力してください'
        />
        <button
          type='submit'
          className='bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded'
        >
          追加
        </button>
      </form>
    </div>
  )
}

useEffectを使用して、初回レンダリング時に全件取得しています。

useEffect(() => {
  const getTodo = async () => {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/todo`)
    const todos = await response.json()
    setTodos(todos)
  }
  getTodo()
}, [])

POST メソッドを送信し、返ってきたデータをsetTodosで更新し、画面に反映させています。

 onChange={async () => {
                const response = await fetch(
                  `${process.env.NEXT_PUBLIC_API_URL}/todo/${todo.id}`,
                  {
                    method: 'PATCH',
                    headers: {
                      'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({ completed: todo.completed }),
                  }
                )
                const updateTodo = await response.json()
                setTodos(
                  todos.map((todo) => {
                    if (todo.id === updateTodo.id) {
                      return updateTodo
                    } else {
                      return todo
                    }
                  })
                )
              }}

更新、削除も同様に返ってきたデータの id を元に更新、削除をしています。

onSubmit={async (e) => {
  e.preventDefault()
  if (!inputValue) {
    alert('入力してください')
  }
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_URL}/todo`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ title: inputValue }),
    }
  )
  const newTodo = await response.json()

  setTodos([...todos, newTodo])
  setInputValue(null)
}}

onClick={async (e) => {
  e.preventDefault()
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_URL}/todo/${todo.id}`,
    {
      method: 'DELETE',
    }
  )
  const deleteTodo = await response.json()
  // 削除したデータを除いた配列を作成
  setTodos(todos.filter((todo) => todo.id !== deleteTodo.id))
}}

最後に

この記事では、Next.js を使用してデータベースと連携しながら ToDo リストを作成する基本的な手順を学びました。
特に、データベースとの連携は、ポートフォリオを作成する際に必要になってくることがあるので、ぜひ覚えておいてください。
今回は、初心者の方向けの記事だったので、簡潔にしていますが、「エラーハンドリング」や「ローディング」などの実装も必要になってきます。
そもそも、今のままでは自分が追加した ToDo を他の人も見れてしまう状況なので、「ログイン機能」も実装する必要があります。
次の記事では、そのあたりの実装をしていきたいと考えています。

Discussion