Next.js(App Router) + shadcn/ui + drizzle orm + supabaseで簡易的TODOアプリ
今回は Next.js(App Router)を使った簡易的なアプリケーション開発を通して、最近流行りの技術についてキャッチアップしていこうと思います。
少しでも参考になればと思い、記事にまとめました。
サンプルリポジトリは以下です
技術紹介
Nextjs
Vue.jsと同じぐらい人気のあるReactフレームワーク。
最近appディレクトリーがstableになり、今回はappディレクトリを使用して実装しています。
shadcn/ui
Radix UI と Tailwind CSS を使用して構築された再利用可能なコンポーネント。
必要なコンポーネントのみをインストールして利用可能であり、また仕様に合わせてをUI簡単に拡張することが可能。
使用感としてNextjsとの相性も良さそう。
Drizzle ORM
DrizzleはヘッドレスTypeScript ORMで、シンプルかつ効果的なORM。
SQLライクなクエリとリレーショナルAPIを組み合わせ、高性能と柔軟性を持っており、サーバーレスに対応し、複数のデータベースと接続が可能です。
今最も勢いのあるORMです。
Supabase
オープンソースのFirebase代替と呼ばれてるBaaSです。
認証機能、データベース、ストレージなど幅広く機能が提供されています。
また、PostgreSQLをベースとしているためRDB経験者にとっては、とっつきやすいと思います。
セットアップ
Nextjsのプロジェクト作成
Next.jsをインストールする。
pnpm create next-app@latest my-app --typescript --tailwind --eslint
今回はpnpmと呼ばれるパッケージマネージャーを使用します。
appディレクトリを使用するためnext.config.jsを以下のように追加する。
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
appDir: true,
},
}
module.exports = nextConfig
shadcn/uiの導入
shadcn/uiのインストールする。
pnpm dlx shadcn-ui@latest init
shadcn/uiの初期設定をコマンドで実行する。
Would you like to use TypeScript (recommended)? no / yes
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Where is your global CSS file? › › src/app/globals.css
Do you want to use CSS variables for colors? › no / yes
Where is your tailwind.config.js located? › tailwind.config.js
Configure the import alias for components: › @/features/shadcn
Configure the import alias for utils: › @/lib/utils
Are you using React Server Components? › no / yes
今回必要なuiコンポーネントを先にインストールする。
pnpm dlx shadcn-ui@latest add button
pnpm dlx shadcn-ui@latest add dialog
pnpm dlx shadcn-ui@latest add table
pnpm dlx shadcn-ui@latest add label
pnpm dlx shadcn-ui@latest add input
pnpm dlx shadcn-ui@latest add textarea
Drizzle ORMの導入
以下のコマンドでパッケージを追加します。
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
Drizzle ORMの設定ファイルの作成する。
touch drizzle.config.ts
drizzle.config.tsを以下のように作成する。
import type { Config } from "drizzle-kit"
const drizzleConfig = {
schema: "./src/db/schema.ts",
out: "./src/db/migrations",
breakpoints: true,
driver: "pg",
dbCredentials: {
connectionString: "ここにSupabaseのDATABASE_URIを入れる",
},
} satisfies Config
export default drizzleConfig
src内でdbフォルダーを作成し、以下のコマンドで3つのファイルを作成する。
touch index.ts
touch schema.ts
touch migrate.ts
index.tsを以下のように作成する
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const connectionString = "ここにSupabaseのDATABASE_URIを入れる";
const client = postgres(connectionString);
const db = drizzle(client, { schema });
export default db;
schema.tsを以下のように作成する。
import { pgTable, serial, text, uuid, timestamp, pgEnum, integer, boolean, json, primaryKey } from "drizzle-orm/pg-core"
import { type InferSelectModel, type InferInsertModel } from "drizzle-orm"
/**
* テーブル名: todos
* テーブル説明: TODOリスト
*/
export const todos = pgTable("todos", {
id: serial("id").primaryKey(),
author: text("author").notNull(), // 作成者
title: text("title").notNull(), // タイトル
description: text("description").notNull(), // 説明
updated_at: timestamp("updated_at").notNull().defaultNow(), // 更新日時
created_at: timestamp("created_at").notNull().defaultNow(), // 作成日時
})
export type SelectTodo = InferSelectModel<typeof todos>
export type InsertTodo = InferInsertModel<typeof todos>
今回は簡易的なTODOアプリなので、テーブルとカラムは上記のみです。
また、スキーマで定義したものを型として定義することも可能なので、今回下記の2行も追加する。
migrate.tsを以下のように作成する。
import { migrate } from "drizzle-orm/postgres-js/migrator"
import drizzleConfig from "../../drizzle.config"
import db from "."
export const migrateDB = async () => {
await migrate(db, { migrationsFolder: drizzleConfig.out })
}
migrateDB()
package.json に以下のスクリプトを追加しておきます。
...
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
+ "db:generate": "pnpm drizzle-kit generate:pg --config drizzle.config.ts",
+ "db:push": "pnpm drizzle-kit push:pg --config drizzle.config.ts"
},
...
マイグレーションファイルの生成やデータベースへのプッシュの時に使用します。
Supabaseの導入
以下のコマンドでデータベースにテーブルを追加する。
pnpm db:generate
pnpm db:push
以下のように表示されていれば成功です。
機能要件
今回実装する機能は以下の5つです。
- TODOの追加機能
- TODOの削除機能
- TODOの全件取得機能
- TODOの更新機能
- TODOの個別取得機能
今後追加するとしたら…
- 認証機能
- 優先度の設定機能
- TODO一覧のフィルター&ソート機能
- カテゴリーの設定機能 ...etc
コード解説
ディレクトリ構成は、こちらのリポジトリを参考にしています。
airbnbのアプリケーションのクローンをNextjs(App Router)で実装しているのでかなり勉強になります。
フロントエンドで見た目を作成
一覧ページ
import { TodoTable } from "@/features/todo/TodoTable"
import getTodos from "./actions/getTodos"
import { AddTodoForm } from "@/features/todo/AddTodoForm"
const Page = async () => {
const todos = await getTodos()
return (
<div className="flex flex-col p-12">
<div className="flex flex-row justify-between mb-12">
<h1 className="text-[24px] font-bold">TODO一覧</h1>
<AddTodoForm />
</div>
<div className="max-w-[800px] w-full mx-auto">
<TodoTable todos={todos} />
</div>
</div>
)
}
export default Page
Todoの追加フォームコンポーネント
"use client"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/features/shadcn/ui/alert-dialog"
import { Label } from "@/features/shadcn/ui/label"
import { Input } from "@/features/shadcn/ui/input"
import { Textarea } from "@/features/shadcn/ui/textarea"
import { useState } from "react"
export const AddTodoForm = () => {
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [author, setAuthor] = useState("")
const addTodo = async () => {
const res = await fetch("/api/todo/create", {
method: "POST",
body: JSON.stringify({
title,
description,
author,
}),
})
const json = await res.json()
}
return (
<AlertDialog>
<AlertDialogTrigger className="bg-primary text-background px-4 py-1 text-[14px] rounded-sm">+ TODO追加</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>TODOの追加</AlertDialogTitle>
</AlertDialogHeader>
<div className="flex flex-col gap-8 mb-8">
<div className="flex flex-col gap-2">
<Label className="font-bold">タイトル</Label>
<Input value={title} onChange={e => setTitle(e.target.value)} />
</div>
<div className="flex flex-col gap-2">
<Label className="font-bold">詳細</Label>
<Textarea value={description} onChange={e => setDescription(e.target.value)} />
</div>
<div className="flex flex-col gap-2">
<Label className="font-bold">作成者</Label>
<Input value={author} onChange={e => setAuthor(e.target.value)} />
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>キャンセル</AlertDialogCancel>
<AlertDialogAction onClick={addTodo}>追加</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
Todoのテーブル一覧コンポーネント
"use client"
import { SelectTodo } from "@/db/schema"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/features/shadcn/ui/table"
import Link from "next/link"
type Props = {
todos: SelectTodo[]
}
export const TodoTable = ({ todos }: Props) => {
return (
<Table className="border">
<TableHeader>
<TableRow>
<TableHead>TODO LIST</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{todos.map(todo => {
return (
<TableRow>
<TableCell className="cursor-pointer">
<Link href={`/${"1"}`}>
<div className="flex flex-col">
<p>{todo.title}</p>
<p>{todo.author}</p>
</div>
</Link>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)
}
詳細ページ
import { DetailTodo } from "@/features/todo/DetailTodo"
import getTodo from "../actions/getTodo"
import { DeleteTodo } from "@/features/todo/DeleteTodo"
interface Props {
id: string
}
const Page = async ({ params }: { params: Props }) => {
const todo = await getTodo(params)
return (
<div className="p-12">
<div className="flex flex-row justify-between mb-12">
<h1 className="text-[24px] font-bold">TODO詳細</h1>
<DeleteTodo />
</div>
<div className="max-w-[800px] w-full mx-auto">
<DetailTodo todo={todo} />
</div>
</div>
)
}
export default Page
削除ボタンコンポーネント
"use client"
import { useParams, useRouter } from "next/navigation"
import { Button } from "../shadcn/ui/button"
export const DeleteTodo = () => {
const router = useRouter()
const params = useParams()
const { id } = params
const deleteTodo = async () => {
const res = await fetch("/api/todo/delete", {
method: "POST",
body: JSON.stringify({
id: id,
}),
})
router.push("/")
}
return <Button onClick={deleteTodo}>削除する</Button>
}
詳細フォームコンポーネント
"use client"
import { SelectTodo } from "@/db/schema"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import { Label } from "../shadcn/ui/label"
import { Input } from "../shadcn/ui/input"
import { Textarea } from "../shadcn/ui/textarea"
import { Button } from "../shadcn/ui/button"
type Props = {
todo: SelectTodo[]
}
export const DetailTodo = ({ todo }: Props) => {
const params = useParams()
const { id } = params
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [author, setAuthor] = useState("")
const updateTodo = async () => {
const res = await fetch("/api/todo/update", {
method: "POST",
body: JSON.stringify({
id,
title,
description,
author,
}),
})
const json = await res.json()
}
useEffect(() => {
setTitle(todo[0].title)
setDescription(todo[0].description)
setAuthor(todo[0].author)
}, [todo])
return (
<div className="flex flex-col gap-8 mb-8">
<div className="flex flex-col gap-2">
<Label className="font-bold">タイトル</Label>
<Input value={title} onChange={e => setTitle(e.target.value)} />
</div>
<div className="flex flex-col gap-2">
<Label className="font-bold">詳細</Label>
<Textarea value={description} onChange={e => setDescription(e.target.value)} />
</div>
<div className="flex flex-col gap-2">
<Label className="font-bold">作成者</Label>
<Input value={author} onChange={e => setAuthor(e.target.value)} />
</div>
<div>
<Button onClick={updateTodo}>更新</Button>
</div>
</div>
)
}
App Router で Actionsを作成
actionsでは、サーバー上でのデータを取得する関数を実装します。
fetchを使った処理やキャッシュ、再検証などの動作を設定することも可能です。
今回は、Todoの全件取得、Todoの個別情報を取得する関数を作成します。
Todoの全件取得
import db from "@/db"
import { SelectTodo, todos } from "@/db/schema"
export default async function getTodos() {
try {
const selectTodos: SelectTodo[] = await db.select().from(todos)
if (!selectTodos) return []
return selectTodos
} catch (error: any) {
console.log(error)
throw new Error(error)
}
}
Todoの詳細取得
import db from "@/db"
import { SelectTodo, todos } from "@/db/schema"
import { eq } from "drizzle-orm"
interface Props {
id: string
}
export default async function getTodo(params: Props) {
try {
const { id } = params
const number_id = Number(id)
const selectTodo: SelectTodo[] = await db.select().from(todos).where(eq(todos.id, number_id))
if (!selectTodo) return []
return selectTodo
} catch (error: any) {
console.log(error)
throw new Error(error)
}
}
App Router で API の作成
今回は、作成・更新・削除の簡易的なAPIを作成します。
Todoの作成処理
import db from "@/db"
import { todos } from "@/db/schema"
import { NextResponse } from "next/server"
export async function POST(request: Request) {
const body = await request.json()
const { title, description, author } = body
const todo = await db.insert(todos).values({ title: title, description: description, author: author }).returning()
return NextResponse.json({ todo })
}
Todoの更新処理
import db from "@/db"
import { todos } from "@/db/schema"
import { eq } from "drizzle-orm"
import { NextResponse } from "next/server"
export async function POST(request: Request) {
const body = await request.json()
const { id, title, description, author } = body
const todo = await db.update(todos).set({ title: title, description: description, author: author }).where(eq(id, id)).returning()
return NextResponse.json({ todo })
}
Todoの削除処理
import db from "@/db"
import { todos } from "@/db/schema"
import { eq } from "drizzle-orm"
import { NextResponse } from "next/server"
export async function POST(request: Request) {
const body = await request.json()
const { id } = body
const todo = await db.delete(todos).where(eq(id, id))
return NextResponse.json({ todo })
}
感想
今回の課題としてはもう少しコードを最適化できたなと思ったのと、Nextjsのserver actionを使ったデータ処理や再検証処理、クライアントコンポーネント・サーバーコンポーネントをもっと切り分ける必要があるな思いました。( Nextjs難しいです💦 )
おまけ
弊社では、スマホやPC1つで完結する網羅的な教材と、無制限で解ける本番と同形式の模試で、短期間での資格取得を目指すことができる簿記のアプリ 『Funda簿記』 を運営しています。
少しでも興味のある方がいれば、リンクよりアクセスしていただけると幸いです☺️
Discussion