💫

【初心者向け】Next.js+ Prisma + PlanetScale 環境でTODOアプリを作ろう

2024/03/10に公開

はじめに

本記事では以下の点について記載しました。

  • PrismaとPlanetScaleを用いたDBの作成
  • Route Hanlderを使用しAPIを作成
  • axiosを用いてAPIにアクセス

練習としてTODOアプリを作る際はstateのみで作成することが多いですが、本記事では一歩進んで、PrismaとPlanetScaleを用いたDBを使って作成します。
よく使われる組み合わせではあると思いますが思いの外、この環境構築やAPIの使い方についての丁寧な解説がなかったので執筆しました。
筆者はNext.jsの初心者であり、多分に間違いが含まれている可能性があります。ご注意ください。

↓前回のstateのみのTODOアプリの作成記事
https://zenn.dev/k_zumi_dev/articles/70945a5dd620e2

作りたい機能

将来的にログイン認証をつけて個人ごとのデータを表示できるように、まずはDBを操作できるようにします。

  • タスクの表示(Get)
  • タスクの作成(Create)
  • タスクの完了済みへの移行(Update)
  • タスクの削除(Delete)

Next.jsのプロジェクト立ち上げ

いつも通りプロジェクトを立ち上げましょう。

✔ Would you like to use src/ directory? … No / Yesのみデフォルトから変更してNoにしています。

npx create-next-app@latest todo-app

PlanetScaleの設定

ーーーーーーーーーーーー
追記:2024/04/08よりPlanetScaleの無料枠がなくなるそうです。
この記事を再現したい方はご注意ください。
https://planetscale.com/blog/planetscale-forever
代替サービスについてはこちらの記事が参考になります。
https://qiita.com/Kenta_Kobayashi/items/ba445245ecd357dd19d7
ーーーーーーーーーーーー

PlanetScaleとは、サーバーレスかつスケーラブルなDBのサービスです。
管理が簡単便利!で最近アツいらしい。無料プランでは1つのDBを作成できます。(←追記:変更があり無料枠がなくなりました。)

まずは、サインアップします。
https://app.planetscale.com/

New Databaseボタンから新しいDBを作成します。
名前を設定し、AWSのリージョンを選び、料金プランをFreeに設定して作成しましょう。

するとこのようにどの言語やフレームワークで使うかを聞かれるので、Prismaを選びます。

あとは指示を読みつつやっていきましょう。
データベースのUsernameとPasswordを設定します。

もう少し下に出ている.envに記載のDATABASE_URLをメモしておきます。
後ほどprismaとの接続に使います。

DATABASE_URL='mysql・・・・・・'

Prismaの設定

こちらの記事を参考にしました。
https://zenn.dev/smish0000/articles/f1a6f463417b65

ターミナルでprismaのパッケージをインストールしましょう。

ターミナル
npm install prisma

初期化します。

ターミナル
npx prisma init

.envに先ほどメモしておいたDATABASE_URLを記載して、2つを接続できるようにします。

.env
DATABASE_URL='mysql・・・・・・'

先ほど初期化した際に、プロジェクト内にprismaフォルダが作成され、schema.prismaが入っているはずです。
以下のコードをコピペしましょう。

prisma/schema.prisma
datasource db {
  provider     = "mysql"
  url          = env("DATABASE_URL")
  relationMode = "prisma"
}

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

実際に使いたいDBのスキーマを書き足してしていきます。
今回は最低限の機能として、TodoTableモデルの中に以下の内容を記載しました。

  • id:作成時に自動でユニークなIDが振られる
  • task:実際のタスクの文字列
  • completed:完了未完了のフラグ、デフォルトは未完了状態になるように設定
prisma/schema.prisma
datasource db {
  provider     = "mysql"
  url          = env("DATABASE_URL")
  relationMode = "prisma"
}

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

model Todo {
  id String @id @default(uuid())
  text String
  completed Boolean @default(false)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

スキーマを変更したら必ずprisima generateを行いましょう。

npx prisma generate

これを行わないとモデルの生成が行われず、変更が反映されません。
自分の場合、あれ、おかしいな、と思ったprisma generateを行っていない場合がほとんどでした。

Vercelでデプロイした際にPrismaClientInitializationErrorが出た場合

その場合は、buildコマンドにprisma generateを書き足しておきましょう。そうするとプロジェクトがVercel上でビルドされるたびにPrisma Clientが再生成され、PrismaClientInitializationErrorが解消できます。

package.json
"scripts": {
    "build": "prisma generate && next build"
}

次にDBをPlanetScaleに反映しましょう。

npx prisma db push

通常、DBの変更を反映させる場合、開発環境ではprisma db pushprisma migrate devを使い、本番環境ではprisa migrateを使いますが、PlanetScaleに反映する場合は、prisma db pushが推奨されています。
(マイグレーションファイルを作る作らないといった違いがあります。)

https://zenn.dev/praha/articles/e15f21dad7b3ee

https://www.prisma.io/docs/orm/overview/databases/planetscale#:~:text=Prisma recommends not using prisma migrate when making schema changes with PlanetScale. Instead%2C we recommend that you use the prisma db push command.

これで、DBがPlanetScaleに反映されました。

各変数・プロパティの関係性の整理

db
prismaクライアントのインスタンス
これにcreateなどのメソッドを適用してデータベースの操作が行える。
今回はlib/db.tsでグローバルスコープで定義する。

↓ db.todo

todo(←prismaではスキーマでモデル名を大文字で指定したとしても最初が小文字になる)
prismaクラインアントのプロパティ
つまり、dbの中のtodoテーブルのこと。

↓ const todos = await db.todo.findMany();

todos
findManyメソッドで取得したデータを格納した変数
今回は配列が入っている。

↓ todos.map((todo) => ...)

todo(←map関数の中で使用される変数)
todoアイテム
配列の中の一要素であり、それぞれの行が持っているプロパティを表す。

↓ todo.textなど

  • id:作成時に自動でユニークなIDが振られる
  • text:実際のタスクの文字列
  • completed:完了未完了のフラグ、デフォルトは未完了状態になるように設定
    todoアイテムのプロパティ

APIの作成

データベースの操作ができるようにAPIを作成します。

PrismaClientのインスタンスを作成

今回はlib/db.tsにて、グローバルスコープで定義します。
コードごとに毎回インスタンスを作成し直すとパフォーマンスが低下するため、今後の機能拡張を見据えて、グローバルスコープでPrismaClientのインスタンスを一度だけ作成し、それを再利用する形式にします。

使い方はdbにアクセスしたいコード内でimport { db } from "@/lib/db";とインポートするだけです。

lib/db.ts
import { PrismaClient } from "@prisma/client";

declare global {
  var prisma: PrismaClient | undefined;
}

export const db = globalThis.prisma || new PrismaClient();

if (process.env.NODE_ENV !== "production") globalThis.prisma = db;

ルートハンドラーの作成

Next.jsのapp routerではAPIは必ずroute.tsに記載します。
誤ってpage router時代のドキュメントを読んで混同しないように気をつけましょう。
https://nextjs.org/docs/app/building-your-application/routing/route-handlers

よって、app/api/todo/route.tsに以下のコードを記載します。
APIにアクセスするには、/api/todoでアクセスできます。これはフロントエンドを書く際に後ほど出てきます。

app/api/todo/route.ts
import { db } from "@/lib/db";
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  const todos = await db.todo.findMany();
  return NextResponse.json(todos);
}

export async function POST(req: NextRequest) {
  const { text } = await req.json();
  console.log(text);

  const todo = await db.todo.create({
    data: {
      text: text,
    },
  });
  return NextResponse.json(todo);
}

export async function PUT(req: NextRequest) {
  const { id, completed } = await req.json();
  console.log(id, completed);

  const changeFlag = await db.todo.update({
    where: {
      id: id,
    },
    data: {
      completed: {
        set: !completed,
      },
    },
  });
  return NextResponse.json(changeFlag);
}

export async function DELETE(req: NextRequest) {
  const { id } = await req.json();
  const deleteTodo = await db.todo.delete({
    where: {
      id: id,
    },
  });
  return NextResponse.json(deleteTodo);
}

4つのHTTPメソッドの説明

基本的な4種類の機能をCreate, Read, Update, Deleteの頭文字を取ってCRUD(クラッド)と呼びます。

実際のメソッド名はデータの作成(Create)、取得(Get)、更新(Put)、削除(Delete)の4種類です。
CRUDとメソッド名と一致していないところがわかりにくいですね。

なお、req: NextRequestにはフロントエンドから渡された値が入っており、それをconst { text } = await req.json();のように記載することで、値を取り出すことができます。

ちなみにrequestの型はいくつかあるのですが、以下の関係になっているようです。
私ははじめはpage routerでの記事を参考にしておりNextApiRequestで記載してしまったため動作せず苦労しました。
また2024/03/10時点ではChatGPTやGithub Copilotはapp routerについての知識が不十分なため、コード生成させるとNextApiRequestで作成しようとしてきます。
注意しましょう。

  • NextApiRequest:page router用なのでapp routerは使えない
  • NextRequest:Requestの拡張クラス さらに扱える内容が増える
  • Request:一番標準

https://nextjs.org/docs/app/building-your-application/routing/route-handlers#extended-nextrequest-and-nextresponse-apis

データの取得(Get)

GETメソッドが実行されると、現在DBに存在するすべてのデータを取得して、todosに代入します。
その後更新があったTODOは一番上に表示されていてほしいので、updatedAtの値で降順にソートして、returnします。

export async function GET(req: NextRequest) {
  const todos = await db.todo.findMany();
  const sortedTodos = todos.sort(
    (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
  );
  return NextResponse.json(sortedTodos);
}

データの作成(Create)

フロントエンドから受け取った値をtextに代入し、db.todo.createでtextプロパティにその値を入れて新規データを作成します。
これは新しいTODOを作成することに相当します。

export async function POST(req: NextRequest) {
  const { text } = await req.json();
  console.log(text);

  const todo = await db.todo.create({
    data: {
      text: text,
    },
  });
  return NextResponse.json(todo);
}

データの更新(Put)

実行されるとcompletedの値を入れ替えます。
これはTODOの完了未完了の値を変更することに相当します。

id, completedの値を受け取り、whereで選択されたTODOのidと合致するデータを選択して、そのcompletedプロパティの真偽値を逆転させます。

export async function PUT(req: NextRequest) {
  const { id, completed } = await req.json();
  console.log(id, completed);

  const changeFlag = await db.todo.update({
    where: {
      id: id,
    },
    data: {
      completed: {
        set: !completed,
      },
    },
  });
  return NextResponse.json(changeFlag);
}

データの削除(Delete)

putの似たように、idが合致するデータを削除します。

export async function DELETE(req: NextRequest) {
  const { id } = await req.json();
  const deleteTodo = await db.todo.delete({
    where: {
      id: id,
    },
  });
  return NextResponse.json(deleteTodo);
}

フロントエンドの作成

必要なパッケージのインストール

shadcn-uiでUIまわり

npx shadcn-ui@latest init

npx shadcn-ui@latest add input
npx shadcn-ui@latest add button

https://ui.shadcn.com/docs/components/input
https://ui.shadcn.com/docs/components/button

lucide-reactでチェックボックスと削除ボタンのアイコン

npm install lucide-react

https://kewpie13.hatenablog.com/entry/2022/12/02/080046

axiosでAPIを叩けるようにする

npm i axios

フロントエンドのコード

今後の拡張を見越して<PrismaTodoList>としてコンポーネント化していますが、直接page.tsxに記載しても問題ありません。

app/page.tsx
"use client";
import { PrismaTodoList } from "@/components/PrismaTodoList";

export default function Home() {
  return (
    <main>
      <h1 className="flex justify-center text-gray-800 font-bold text-2xl mt-6">
        TODOリスト
      </h1>
      <PrismaTodoList></PrismaTodoList>
    </main>
  );
}
components/PrismaTodoList.tsx
import { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import axios from "axios";
import { Square, CheckSquare, Trash } from "lucide-react";

export const PrismaTodoList = () => {
  // TODOリストの状態を管理するための変数
  const [todos, setTodos] = useState<
    { id: string; text: string; completed: boolean }[]
  >([]);
  const [text, setText] = useState<string>("");

  // テキストフィールドの値が変更されたときに実行される関数
  const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
    console.log(e.target.value + "にtextが変更されました");
  };

  // Get:APIからTODOリストを取得する関数
  const fetchTodos = async () => {
    const response = await axios.get("/api/todo");
    setTodos(response.data);
  };

  // Create:新しいTODOを追加する関数
  const addTodo = async (text: string) => {
    if (text === "") {
      console.log("空では追加できません");
      return;
    }

    const response = await axios.post("/api/todo", {
      text,
    });
    console.log(text + "を追加しました");
    setText("");
    fetchTodos();
  };

  // Put:TODOの完了フラグを切り替える関数
  const completeTodo = async (id: string, completed: boolean) => {
    const response = await axios.put("/api/todo", {
      id,
      completed,
    });
    console.log(id + "の完了フラグを変更しました");
    fetchTodos();
  };

  // Detele:TODOを削除する関数
  const deleteTodo = async (id: string) => {
    const response = await axios.delete("/api/todo", {
      data: { id },
    });
    console.log(id + "を削除しました");
    fetchTodos();
  };

  // コンポーネントがマウントされたときにTODOリストを取得する
  useEffect(() => {
    fetchTodos();
  }, []);

  return (
    <div className="flex flex-col items-center justify-center text-center space-y-4 mt-5 mx-auto w-4/5 pd-8 pb-8">
      <Input
        className=" bg-zinc-300/50"
        placeholder="input TODO"
        value={text}
        onChange={changeText}
        onKeyDown={(e) => {
          if (e.key === "Enter" && !e.nativeEvent.isComposing) {
            e.preventDefault();
            addTodo(text);
          }
        }}
      />
      <Button className="w-full my-5 bg-zinc-500" onClick={() => addTodo(text)}>
        add
      </Button>
      <div className="w-full container my-auto bg-zinc-300 rounded-sm py-4">
        {todos
          .filter((todo) => !todo.completed)
          .map((todo) => (
            <div
              key={todo.id}
              className="w-full flex items-center mt-4 p-4 bg-white rounded-sm space-x-4">
              <button
                className="p-2 rounded-full flex-none"
                onClick={() => completeTodo(todo.id, todo.completed)}>
                <Square className="hover:text-green-500" />
              </button>
              <p className="flex-auto">{todo.text}</p>
              <Trash
                className="flex-none hover:text-red-500"
                onClick={() => deleteTodo(todo.id)}
              />
            </div>
          ))}
        <div className="my-10"></div>
        {todos
          .filter((todo) => todo.completed)
          .map((todo) => (
            <div
              key={todo.id}
              className="w-full flex items-center mb-4 p-4 bg-gray-200 rounded-sm space-x-4">
              <button
                className="p-2 rounded-full flex-none"
                onClick={() => completeTodo(todo.id, todo.completed)}>
                <CheckSquare className="hover:text-green-700" />
              </button>
              <p className="flex-auto">{todo.text}</p>
              <Trash
                className="flex-none hover:text-red-500"
                onClick={() => deleteTodo(todo.id)}
              />
            </div>
          ))}
      </div>
    </div>
  );
};

axiosでのAPIの叩き方

axios.get("/api/todo")のように、使用したいメソッドとAPIのURLを記載します。
本記事でも事前に作成したようにAPIのroute.tsでは対応するメソッドを記載しておく必要があります。
以下ではaxios.getとGETが対応していますね。

呼び出す方

components/PrismaTodoList.tsx
  // Get:APIからTODOリストを取得する関数
  const fetchTodos = async () => {
    const response = await axios.get("/api/todo");
    setTodos(response.data);
  };

呼び出される方(API)

app/api/todo/route.ts
export async function GET(req: NextRequest) {
  const todos = await db.todo.findMany();
  const sortedTodos = todos.sort(
    (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
  );
  return NextResponse.json(sortedTodos);
}
axiosとfetchの違いは何ですか?

fetchとaxiosはどちらもHTTPリクエストを行うためのJavaScriptライブラリですが、いくつかの重要な違いがあります。

エラーハンドリング: fetchはHTTPエラーステータス(404、500など)でもPromiseをrejectしません。それはネットワークエラー(ユーザーがオフラインなど)でのみrejectします。一方、axiosはHTTPエラーステータスでもPromiseをrejectします。

ブラウザのサポート: fetchは比較的新しいAPIで、古いブラウザではサポートされていません。一方、axiosは古いブラウザでも動作します。

リクエストの中止: axiosはリクエストの中止をサポートしていますが、fetchはまだこの機能をサポートしていません。

レスポンスの解析: axiosは自動的にJSONレスポンスを解析しますが、fetchでは手動で.json()を呼び出す必要があります。

タイムアウト: axiosはリクエストのタイムアウトを設定することができますが、fetchはこの機能をサポートしていません。

以上のような違いがあるため、どちらを使用するかはプロジェクトの要件によります。

components/PrismaTodoList.tsx

値を渡して、画面が更新されるまで

例えばaddTodoでは入力欄に入力されたテキストをAPIのメソッドに渡して、その値でデータベースに新しいデータを作成します。

引数がある場合は、onClick={() => addTodo(text)}>のようにアロー関数で関数を呼び出します。
アロー関数を使わずにonClick={addTodo(text)}>と記載すると、ボタンがクリックされたときではなく、画面が呼び出された瞬間にaddTodo関数が実行されてしまいます。
https://zenn.dev/kisukeyas/articles/9af27eab7122ce

components/PrismaTodoList.tsx
      <Button className="w-full my-5 bg-zinc-500" onClick={() => addTodo(text)}>
        add
      </Button>

const addTodo = async (text: string) => {}のようにaddTodoでは受け取る引数を定義しておきます。
const response = await axios.post("/api/todo", {text,});の形でaxios.postの引数にURLと渡したい値を記載します。

components/PrismaTodoList.tsx
  // Create:新しいTODOを追加する関数
  const addTodo = async (text: string) => {
    if (text === "") {
      console.log("空では追加できません");
      return;
    }

    const response = await axios.post("/api/todo", {
      text,
    });
    console.log(text + "を追加しました");
    setText("");
    fetchTodos();
  };

reqの中には先ほどaxios.postから渡された値が入っています。
これをconst { text } = await req.json();で、text定数として定義します。
text定数をdb.todo.createでデータのtextプロパティの値として、新規データを作成します。

app/api/todo/route.ts
export async function POST(req: NextRequest) {
  const { text } = await req.json();
  console.log(text);

  const todo = await db.todo.create({
    data: {
      text: text,
    },
  });
  return NextResponse.json(todo);
}

話は再びaddTodo関数に戻ります。先ほど新規データは作成されましたが、画面の再描画をしないと、追加したデータが画面上に表示されません。
そこで、fetchTodos()を実行して、再度データベースから最新のデータを取得します。

components/PrismaTodoList.tsx
  // Create:新しいTODOを追加する関数
  const addTodo = async (text: string) => {
    if (text === "") {
      console.log("空では追加できません");
      return;
    }

    const response = await axios.post("/api/todo", {
      text,
    });
    console.log(text + "を追加しました");
    setText("");
    fetchTodos();
  };
components/PrismaTodoList.tsx
  // Get:APIからTODOリストを取得する関数
  const fetchTodos = async () => {
    const response = await axios.get("/api/todo");
    setTodos(response.data);
  };

fetchTodos()の中でsetTodosが呼ばれて、stateが変更されたので、関係するコンポーネントが再描画される。

components/PrismaTodoList.tsx
  // TODOリストの状態を管理するための変数
  const [todos, setTodos] = useState<
    { id: string; text: string; completed: boolean }[]
  >([]);
  const [text, setText] = useState<string>("");

ローカルで動作確認

ターミナル
pnpm run dev

http://localhost:3000にアクセスして動作確認をしましょう。
正しく設定できていれば、以下のような画面になり、addボタン、チェックボックス、削除機能が使えるはずです。
(F12を押してディベロッパーモードにして表示したものです。)

まとめ

本記事では以下の点について記載しました。

  • PrismaとPlanetScaleを用いたDBの作成
  • Route Hanlderを使用しAPIを作成
  • axiosを用いてAPIにアクセス

stateでのTODOアプリ作成の続きとして参考になれば幸いです。
次回はログイン認証機能を作成する予定です。

Discussion