【初心者向け】Next.js+ Prisma + PlanetScale 環境でTODOアプリを作ろう
はじめに
本記事では以下の点について記載しました。
- PrismaとPlanetScaleを用いたDBの作成
- Route Hanlderを使用しAPIを作成
- axiosを用いてAPIにアクセス
練習としてTODOアプリを作る際はstateのみで作成することが多いですが、本記事では一歩進んで、PrismaとPlanetScaleを用いたDBを使って作成します。
よく使われる組み合わせではあると思いますが思いの外、この環境構築やAPIの使い方についての丁寧な解説がなかったので執筆しました。
筆者はNext.jsの初心者であり、多分に間違いが含まれている可能性があります。ご注意ください。
↓前回のstateのみのTODOアプリの作成記事
作りたい機能
将来的にログイン認証をつけて個人ごとのデータを表示できるように、まずは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の無料枠がなくなるそうです。
この記事を再現したい方はご注意ください。
代替サービスについてはこちらの記事が参考になります。
ーーーーーーーーーーーー
PlanetScaleとは、サーバーレスかつスケーラブルなDBのサービスです。
管理が簡単便利!で最近アツいらしい。無料プランでは1つのDBを作成できます。(←追記:変更があり無料枠がなくなりました。)
まずは、サインアップします。
New Databaseボタンから新しいDBを作成します。
名前を設定し、AWSのリージョンを選び、料金プランをFreeに設定して作成しましょう。
するとこのようにどの言語やフレームワークで使うかを聞かれるので、Prismaを選びます。
あとは指示を読みつつやっていきましょう。
データベースのUsernameとPasswordを設定します。
もう少し下に出ている.envに記載のDATABASE_URLをメモしておきます。
後ほどprismaとの接続に使います。
DATABASE_URL='mysql・・・・・・'
Prismaの設定
こちらの記事を参考にしました。
ターミナルでprismaのパッケージをインストールしましょう。
npm install prisma
初期化します。
npx prisma init
.envに先ほどメモしておいたDATABASE_URLを記載して、2つを接続できるようにします。
DATABASE_URL='mysql・・・・・・'
先ほど初期化した際に、プロジェクト内に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:完了未完了のフラグ、デフォルトは未完了状態になるように設定
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が解消できます。
"scripts": {
"build": "prisma generate && next build"
}
次にDBをPlanetScaleに反映しましょう。
npx prisma db push
通常、DBの変更を反映させる場合、開発環境ではprisma db push
かprisma migrate dev
を使い、本番環境ではprisa migrate
を使いますが、PlanetScaleに反映する場合は、prisma db push
が推奨されています。
(マイグレーションファイルを作る作らないといった違いがあります。)
これで、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";とインポートするだけです。
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時代のドキュメントを読んで混同しないように気をつけましょう。
よって、app/api/todo/route.tsに以下のコードを記載します。
APIにアクセスするには、/api/todoでアクセスできます。これはフロントエンドを書く際に後ほど出てきます。
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:一番標準
データの取得(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
lucide-reactでチェックボックスと削除ボタンのアイコン
npm install lucide-react
axiosでAPIを叩けるようにする
npm i axios
フロントエンドのコード
今後の拡張を見越して<PrismaTodoList>としてコンポーネント化していますが、直接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>
);
}
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が対応していますね。
呼び出す方
// Get:APIからTODOリストを取得する関数
const fetchTodos = async () => {
const response = await axios.get("/api/todo");
setTodos(response.data);
};
呼び出される方(API)
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はこの機能をサポートしていません。
以上のような違いがあるため、どちらを使用するかはプロジェクトの要件によります。
値を渡して、画面が更新されるまで
例えばaddTodoでは入力欄に入力されたテキストをAPIのメソッドに渡して、その値でデータベースに新しいデータを作成します。
引数がある場合は、onClick={() => addTodo(text)}>のようにアロー関数で関数を呼び出します。
アロー関数を使わずにonClick={addTodo(text)}>と記載すると、ボタンがクリックされたときではなく、画面が呼び出された瞬間にaddTodo関数が実行されてしまいます。
<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と渡したい値を記載します。
// 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プロパティの値として、新規データを作成します。
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()を実行して、再度データベースから最新のデータを取得します。
// 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();
};
// Get:APIからTODOリストを取得する関数
const fetchTodos = async () => {
const response = await axios.get("/api/todo");
setTodos(response.data);
};
fetchTodos()の中でsetTodosが呼ばれて、stateが変更されたので、関係するコンポーネントが再描画される。
// 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