📝

Next.js(App Router) + shadcn/ui + drizzle orm + supabaseで簡易的TODOアプリ

2023/09/11に公開

今回は Next.js(App Router)を使った簡易的なアプリケーション開発を通して、最近流行りの技術についてキャッチアップしていこうと思います。
少しでも参考になればと思い、記事にまとめました。

サンプルリポジトリは以下です
https://github.com/nt-mino/todo-app-v1

技術紹介

Nextjs

https://nextjs.org/

Vue.jsと同じぐらい人気のあるReactフレームワーク。
最近appディレクトリーがstableになり、今回はappディレクトリを使用して実装しています。

shadcn/ui

https://ui.shadcn.com/

Radix UI と Tailwind CSS を使用して構築された再利用可能なコンポーネント。
必要なコンポーネントのみをインストールして利用可能であり、また仕様に合わせてをUI簡単に拡張することが可能。

使用感としてNextjsとの相性も良さそう。

Drizzle ORM

https://orm.drizzle.team/

DrizzleはヘッドレスTypeScript ORMで、シンプルかつ効果的なORM。
SQLライクなクエリとリレーショナルAPIを組み合わせ、高性能と柔軟性を持っており、サーバーレスに対応し、複数のデータベースと接続が可能です。

今最も勢いのあるORMです。

Supabase

https://supabase.com/

オープンソースのFirebase代替と呼ばれてるBaaSです。
認証機能、データベース、ストレージなど幅広く機能が提供されています。
また、PostgreSQLをベースとしているためRDB経験者にとっては、とっつきやすいと思います。

セットアップ

Nextjsのプロジェクト作成

Next.jsをインストールする。

pnpm create next-app@latest my-app --typescript --tailwind --eslint

今回はpnpmと呼ばれるパッケージマネージャーを使用します。

https://pnpm.io/ja/

appディレクトリを使用するためnext.config.jsを以下のように追加する。

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を以下のように作成する。

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を以下のように作成する

src/db/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を以下のように作成する。

src/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を以下のように作成する。

src/db/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 に以下のスクリプトを追加しておきます。

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)で実装しているのでかなり勉強になります。

https://github.com/AntonioErdeljac/next13-airbnb-clone/tree/master

フロントエンドで見た目を作成

一覧ページ

src/app/page.tsx
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の追加フォームコンポーネント

/src/features/todo/AddTodoForm.tsx

"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のテーブル一覧コンポーネント

/src/features/todo/TodoTable.tsx
"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>
  )
}

詳細ページ

/app/[id]/page.tsx
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

削除ボタンコンポーネント

/src/features/todo/DeleteTodo.tsx
"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>
}

詳細フォームコンポーネント

/src/features/todo/DetailTodo.tsx
"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を作成

https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating

actionsでは、サーバー上でのデータを取得する関数を実装します。
fetchを使った処理やキャッシュ、再検証などの動作を設定することも可能です。

今回は、Todoの全件取得、Todoの個別情報を取得する関数を作成します。

Todoの全件取得

/app/actions/getTodos.ts
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の詳細取得

/app/actions/getTodo.ts
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 の作成

https://nextjs.org/docs/app/building-your-application/routing/route-handlers

今回は、作成・更新・削除の簡易的なAPIを作成します。

Todoの作成処理

app/api/todo/create/route.ts
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の更新処理

app/api/todo/update/route.ts
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の削除処理

app/api/todo/delete/route.ts
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簿記』 を運営しています。

少しでも興味のある方がいれば、リンクよりアクセスしていただけると幸いです☺️

https://boki.funda.jp/

Discussion