【ハンズオン】Next.js × Drizzle × Supabase：お手軽・簡単WEBアプリ開発

最近流行りの技術やサービスを組み合わせてTODOアプリを作ってみました。

この記事では、
- 開発環境の構築
- Todo機能
- GitHub認証
- ホスティング

をそれぞれハンズオン形式で紹介しています。

# 検証環境
- Node.js v18

## 主な技術スタック
- Frontend
    - Next.js v14
    - UI: shadcn/ui
- Backend
    - Next.js v14
    - ORM: Drizzle v1
- Database
    - Supabase Database
- Auth
    - Supabase Authentication
- Hosting
    - Vercel


## デモ
![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348770/3f80ff68-d16e-350a-7f20-be832030d078.gif)


# 手順
1. はじめに
2. 開発環境の構築
    2-1. コンテナ定義
    2-2. リポジトリの説明
    2-3. アカウント作成
3. アプリ開発
    3-1. 環境セットアップ
    3-2. フロントエンド
    3-3. バックエンド
4. デプロイに向けて
    4-1. Auth認証
    4-2. ホスティング
5. まとめ

# 1. はじめに
使用する各サービスの説明は細かく書いていません。
詳しく知りたい方は都度リンクを貼っているので参照してください。

今回作成するアプリのソースコードは以下のGitHubリポジトリから確認できます。
（以下ハンズオンはGithubリポジトリを作成済みという前提で進めます）

https://github.com/bo-values/next-drizzle-supabase

# 2. 開発環境の構築
## 2-1. コンテナ定義
開発環境を構築します。
VSCode > Remote Containerを利用して構築します。

https://code.visualstudio.com/

https://code.visualstudio.com/docs/devcontainers/containers


gith
`.devcontainer`ディレクトリを作成→`devcontainer.json`を作成します。

```
.devcontainer/
    - devcontainer.json
```

```json:devcontainer.json
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
	"name": "next-drizzle-supabase",
	// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
	"image": "mcr.microsoft.com/devcontainers/typescript-node:1-18-bullseye",
	"features": {
		"ghcr.io/devcontainers-contrib/features/supabase-cli:1": {}
	}

	// Features to add to the dev container. More info: https://containers.dev/features.
	// "features": {},

	// Use 'forwardPorts' to make a list of ports inside the container available locally.
	// "forwardPorts": [],

	// Use 'postCreateCommand' to run commands after the container is created.
	// "postCreateCommand": "yarn install",

	// Configure tool-specific properties.
	// "customizations": {},

	// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
	// "remoteUser": "root"
}
```

VSCode拡張機能`Dev Containers`をインストールし、左側の`><`ボタンをクリック後、`コンテナをリビルド`を実行します。
初回は数秒時間がかかりますが、実行完了後Node v18 の開発環境が立ち上がります。


## 2-2. リポジトリの説明
コンテナを立ち上げることで開発環境をセットアップすることができました。
最終的なリポジトリ構成は以下の想定になります。
```command
.
├── app
│   ├── components.json
│   ├── drizzle.config.ts
│   ├── .env.example
│   ├── .next/
│   ├── src/
│   │   ├── app
│   │   ├── components
│   │   ├── db
│   │   ├── hooks
│   │   ├── lib
│   │   └── pages
│   ├── tailwind.config.ts
│   └── tsconfig.json
├── .devcontainer
│   └── devcontainer.json
└── README.md
```
今回はNext.jsでフロントエンド・バックエンド両方とも開発を進めます。


## 2-3. アカウント作成
DBと認証に Supabase を利用します。
githubアカウントがあればサクッとアカウント作成できるのでおすすめです。

https://supabase.com/

これからSupabaseのプロジェクト作成からDBに接続するDatabaseURIの取得まで行います。
### プロジェクト作成 & URIの取得
はじめにプロジェクトを作成します。
`Create new project`を選択し、`Project Name`と`Database Password`を登録します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348770/d2814218-95d8-c641-edbb-4a52f15c8a35.png)


プロジェクト作成後左サイドメニューの歯車マーク`Project Settings`をクリック後、`Database`を選択、`Database Settings > Connection string > URIタブ`の`postgres://~`から始まる文字列をコピーします。この時、[YOUR-PASSWORD]という箇所を先ほど設定した`Database Password`に置き換えて控えておきます。

![スクリーンショット 2024-02-07 19.15.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348770/86e0634c-8aea-551b-f51b-8bd5e33dd610.png)

開発アプリが接続するDB URIの設定が完了しました。
これでDB接続に必要な設定は終了です。



# 3. アプリ開発
## 3-1. 環境セットアップ
Next.jsをインストールします。

https://nextjs.org/

コンテナを立ち上げると`/workspaces/next-drizzle-supabase|[your repository name]`にいるのでそこで以下のコマンドを実行します。
```command:/workspaces/next-drizzle-supabase $
$ mkdir app
$ cd app
$ npx create-next-app@latest . --typescript --tailwind --eslint

✔ 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
Creating a new Next.js app in /workspaces/next-drizzle-supabase/app.

Using npm.

Initializing project with template: app-tw 


Installing dependencies:
- react
- react-dom
- next

Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- autoprefixer
- postcss
- tailwindcss
- eslint
- eslint-config-next
.
.
Success! Created app at /workspaces/next-drizzle-supabase/app
```
インストールが完了すれば、`npm run dev`を実行し`localhost:3000`で Next.js の初期画面が表示されていることを確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348770/1fbfd987-d21c-a1c7-b35b-7a4a072d9d22.png)

## 3-2. フロントエンド
これからフロントエンドを実装します。
Next.js をインストールしたときにしれっと`Tailwind CSS`も合わせてセットアップしています。スタイリングはこちらに任せることにします。
また、コンポーネントを一から作るのはだるいので、世に溢れるコンポーネントサービスを使うことにします。
今回は昨今流行りの`shadcn/ui`を使ってみます。

https://ui.shadcn.com/

なんですかそれ？
従来のコンポーネントライブラリと何が違うの？

という点に関しては過去に自身で記事にしたので参考にしてみて下さい。

https://qiita.com/bee-yan/items/33214781b8dd991e2768

脱線しましたが、UIの部分はほぼ`shadcn/ui`に頼りっぱなしで進めることにします。

`shadcn-ui@latest init`した後、必要なコンポーネントを先にインストールします。
```command:/workspaces/next-drizzle-supabase/app $
$ npx shadcn-ui@latest init
✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Gray
✔ Would you like to use CSS variables for colors? … no / [yes]

✔ Writing components.json...
✔ Initializing project...
✔ Installing dependencies...

Success! Project initialization completed. You may now add components.

$ npx shadcn-ui@latest add input button label card
✔ Done.
```
これで必要なベースコンポーネントを`src/components/ui/`に配置することができました。

次にこれらのコンポーネントを表示させます。
`src/app/page.tsx`を以下のように修正します。
```tsx:src/app/pages.tsx
import Todo from "@/components/pages/Todo"

export default function Home() {
  return (
    <Todo />
  )
}
```

次に Todoコンポーネントを配置します。
```command: src/components
.
├── pages
│   └── Todo
│       └── index.tsx
└── ui
    ├── button.tsx
    ├── card.tsx
    ├── input.tsx
    └── label.tsx
```

<details>
<summary>Todoコンポーネント</summary>
<div>

`useEffect() > getTasks()`で一旦想定するデータ(Mock)を返す想定にします。
また、ユーザーイベントを `function` で定義します。
- フロントエンドで完結する処理は`handle`を関数名の頭につける
- それ以外は状態に応じて正しい命名をつける

としています。

```tsx: src/components/pages/Todo/index.tsx
"use client"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { CardContent, Card } from "@/components/ui/card"
import { ChangeEvent, useEffect, useState } from "react"
import { Check, PencilLine, Plus, RefreshCcw, Trash } from "lucide-react"

type Task = {
    /**
     * Task ID
     */
    id: string

    /**
     * Task Title
     */
    label: string
}


export default function Index() {
    const [refetch, setRefetch] = useState(true)
    const [addText, setAddText] = useState("")
    const [updateText, setUpdateText] = useState("")
    const [isUpdateMode, setIsUpdateMode] = useState(false)
    const [taskId, setTaskId] = useState("")
    const [tasks, setTasks] = useState<Task[]>([])

    useEffect(() => {
        if (!refetch) return
        (async () => {
            const data = await getTasks()
            setTasks(data)
            setRefetch(false)
        })()
    }, [refetch, setTasks])

    async function getTasks() {
        return [
            {
                id: "task-1",
                label: "Buy groceries"
            },
            {
                id: "task-2",
                label: "Finish project report"
            },
            {
                id: "task-3",
                label: "Call the doctor"
            }
        ]
    }

    /**
     * refresh all state
     */
    function refreshAll() {
        setTasks([])
        setAddText("")
        setUpdateText("")
        setIsUpdateMode(false)
        setTaskId("")
        setRefetch(true)
    }

    /**
     * handle Update Mode
     * 
     * @param taskId 
     * @param taskText 
     */
    function handleUpdateMode(taskId: string, taskText: string) {
        setTaskId(taskId)
        setUpdateText(taskText)
        setIsUpdateMode(!isUpdateMode)
    }

    /**
     * handle Update Task Text
     * @param element 
     */
    function handleUpdateText(element: ChangeEvent<HTMLInputElement>) {
        setUpdateText(element.target.value)
    }

    /**
     * execute "Add" Task
     */
    function addTask() {
        console.log(addText)
        // add process here
        refreshAll()
    }

    /**
     * execute "Update" Task
     * @param taskId 
     */
    function updateTask(taskId: string) {
        console.log({ taskId, updateText })
        // update process here
        setIsUpdateMode(!isUpdateMode)
        setTaskId("")
        setUpdateText("")
    }

    /**
     * execute "Delete" Task
     * @param taskId 
     */
    function deleteTask(taskId: string) {
        console.log(taskId)
        // delete process here
    }


    return (
        <main className="w-full max-w-2xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
            <div className="flex justify-start items-center">
                <h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">Todo List</h1>
                <div className="ml-2">
                    <RefreshCcw className="cursor-pointer" onClick={refreshAll} />
                </div>
            </div>
            <div className="mt-6 space-y-4" >
                <div className="space-y-1">
                    <Label htmlFor="task">New Task</Label>
                    <Input id="task" placeholder="Enter a new task" value={addText} onChange={(event) => setAddText(event.target.value)} />
                </div>
                <Button type="button" onClick={() => addTask()} disabled={addText.length < 1}>
                    <Plus className="h-5 w-5" />
                    <span className="ml-2">Add task</span>
                </Button>
            </div>

            {
                tasks.map(task => {
                    return (
                        <Card key={task.id} className="my-2">
                            <CardContent className="flex items-center justify-between p-4">
                                <div className="flex items-center gap-2 w-full">
                                    {
                                        (taskId !== task.id) && (
                                            <Label className="text-lg" htmlFor="task-2">
                                                {task.label}
                                            </Label>
                                        )
                                    }
                                    {
                                        (isUpdateMode && taskId === task.id) && (
                                            <div className="w-full">
                                                <Input type="text" id={`edit-${task.id}`} value={updateText} onChange={handleUpdateText} className="w-full border-none text-lg" />
                                            </div>
                                        )
                                    }
                                </div>
                                <div className="flex items-center gap-2">
                                    {
                                        (taskId !== task.id) && (
                                            <Button size="icon" variant="ghost" disabled={isUpdateMode} onClick={() => handleUpdateMode(task.id, task.label)}>
                                                <PencilLine className="h-5 w-5" />
                                                <span className="sr-only">Edit task</span>
                                            </Button>

                                        )
                                    }
                                    {
                                        (isUpdateMode && taskId === task.id) && (
                                            <Button size="icon" variant="ghost" onClick={() => updateTask(task.id)}>
                                                <Check className="h-5 w-5" />
                                                <span className="sr-only">Update task</span>
                                            </Button>
                                        )
                                    }
                                    <Button size="icon" variant="ghost" disabled={isUpdateMode} onClick={() => deleteTask(task.id)}>
                                        <Trash className="h-5 w-5" />
                                        <span className="sr-only">Delete task</span>
                                    </Button>
                                </div>
                            </CardContent>
                        </Card>
                    )
                })
            }
            {
                tasks.length < 1 && <div className="text-center">no data</div>
            }
        </main>
    )
}
```

本当は処理別でコンポーネントに分けた方が見やすくて良いですが、今回はその辺を飛ばして次に進みます。
</div>
</details>

ここまででMockで画面表示するところまでできたかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348770/cdac8ec9-acbf-63af-b5e9-3c057c077058.png)

## 3-3. バックエンド

### はじめに
バックエンドAPIを構築する前に、DB接続など諸々を設定します。
`Drizzle`をプロジェクトにインストールします。

https://orm.drizzle.team/

```command: /app
$ npm i drizzle-orm postgres
$ npm i -D drizzle-kit
```
インストール後設定ファイルと`.env`を作成します。
```command: /app
$ touch drizzle.config.ts .env .env.example
```

`.env`と`.env.example`、加えて`.gitignore`を以下のように修正します。
```/app/.env
SUPABASE_DB_URI=************************* // 冒頭で取得したDatabase URI
```
```/app/.env.example
SUPABASE_DB_URI=
```
```/app/.gitignore
# local env files
.env*.local
.env // 追記
```

修正後、`drizzle.config.ts`を以下のように記述します。
```typescript: /app/.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: process.env.SUPABASE_DB_URI || ''
  },
} satisfies Config

export default drizzleConfig
```

次にDBスキーマを定義します。
`app/src`で`db`ディレクトリを作成し、スキーマとマイグレーションファイルを作成します。
```command: /app/src
$ mkdir db
$ cd db
$ touch index.ts schema.ts migrate.ts
```

```ts: /src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

const client = postgres(process.env.SUPABASE_DB_URI || '');
const db = drizzle(client, { schema });

export default db;
```

```ts: /src/db/schema.ts
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"
import { type InferSelectModel, type InferInsertModel } from "drizzle-orm"

/**
 * テーブル: todos
 */
export const todos = pgTable("todos", {
    id: serial("id").primaryKey(),
    label: text("label").notNull(),
    created_at: timestamp("created_at").notNull().defaultNow(),
    updated_at: timestamp("updated_at").notNull().defaultNow(),
})

export type SelectTodo = InferSelectModel<typeof todos>
export type InsertTodo = InferInsertModel<typeof todos>
```

```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()
```

`schema.ts`でテーブルを定義します。今回は`todos`テーブルを定義します。
その後、drizzle ORM から を Supabase へテーブル作成を行います。
以下のように`pacakge.json`に`npm run db:generate`と`npm run db:push`を追加して実行します。
```json:/app/package.json
"scripts": {
.
.
.
  "db:generate": "npx drizzle-kit generate:pg --config drizzle.config.ts",
  "db:push": "npx drizzle-kit push:pg --config drizzle.config.ts"
},
```

```command: /app
$ npm run db:generate
> app@0.1.0 db:generate
> npx drizzle-kit generate:pg --config drizzle.config.ts

drizzle-kit: v0.20.14
drizzle-orm: v0.29.3

Reading config file '/workspaces/next-drizzle-supabase/app/drizzle.config.ts'
1 tables
todos 4 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ src/db/migrations/0000_crazy_may_parker.sql 🚀



$ npm run db:push
> app@0.1.0 db:push
> npx drizzle-kit push:pg --config drizzle.config.ts

drizzle-kit: v0.20.14
drizzle-orm: v0.29.3

Custom config path was provided, using 'drizzle.config.ts'
Reading config file '/workspaces/next-drizzle-supabase/app/drizzle.config.ts'
[✓] Changes applied
```

実行完了後Supabaseにて`todo`テーブルが作成されていることが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348770/f7dc0a57-2b94-4067-dcf4-ae2511f02a52.png)

ここまででSupabaseのDBを利用するところまでの設定を終えました。
次にバックエンドAPIの構築を行います。

### バックエンドAPI 構築

今回は以下の構成でAPIを組みます。
```text: src/pages/api
.
├── task
│   ├── create
│   │   └── index.ts
│   ├── delete
│   │   └── index.ts
│   └── update
│       └── index.ts
└── tasks
    └── get
        └── index.ts
```

それぞれのAPIを以下のように作成します。

<details>
<summary>/api/tasks/get :全てのタスクを取得</summary>

全てのタスクを取得するAPIです。
Supabaseにあるデータを取得して、フロントエンドに返す処理を行います。

<div>

```typescript: src/pages/api/tasks/get/index.ts
import { NextApiRequest, NextApiResponse } from 'next';
import db from "@/db"
import { SelectTodo, todos } from "@/db/schema"
import { asc, desc } from 'drizzle-orm';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    try {
        const selectTodos: SelectTodo[] = await db.select().from(todos).orderBy(desc(todos.id))
        if (selectTodos.length < 1) return []
    
        return res.status(200).json(selectTodos)
      } catch (error: any) {
        console.log(error)
        throw new Error(error)
      }
};
```

</div>
</details>

<details>
<summary>/api/task/create :タスクの作成</summary>
<div>

タスクを作成するAPIです。
新規タスクを作成します。

```typescript: src/pages/api/task/create/index.ts
import { NextApiRequest, NextApiResponse } from 'next';
import db from "@/db"
import { todos } from "@/db/schema"

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    const body = await req.body
    const { label } = JSON.parse(body)

    try {
        const todo = await db.insert(todos).values({ label }).returning()
        return res.status(200).json(todo)
    } catch (error: any) {
        console.log(error)
        throw new Error(error)
    }
};
```

</div>
</details>

<details>
<summary>/api/task/update :タスクの更新</summary>
<div>

タスクを更新するAPIです。
指定IDのデータを更新します。

```typescript: src/pages/api/task/update/index.ts
import { NextApiRequest, NextApiResponse } from 'next';
import db from "@/db"
import { todos } from "@/db/schema"
import { eq } from 'drizzle-orm';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    const body = await req.body
    const { taskId,label } = JSON.parse(body)

    try {
        const todo = await db.update(todos).set({label}).where(eq(todos.id,taskId)).returning()
        return res.status(200).json(todo)
    } catch (error: any) {
        console.log(error)
        throw new Error(error)
    }
};
```

</div>
</details>

<details>
<summary>/api/task/delete :タスクの削除</summary>
<div>

タスクを削除するAPIです。
今回は指定IDに対して物理削除を行います。

```typescript: src/pages/api/task/delete/index.ts
import { NextApiRequest, NextApiResponse } from 'next';
import db from "@/db"
import { todos } from "@/db/schema"
import { eq } from 'drizzle-orm';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    const body = await req.body
    const { taskId } = JSON.parse(body)

    try {
        const todo = await db.delete(todos).where(eq(todos.id,taskId)).returning()
        return res.status(200).json(todo)
    } catch (error: any) {
        console.log(error)
        throw new Error(error)
    }
};
```

</div>
</details>


これでほぼバックエンドAPIの構築が完了しました。

# 4. デプロイに向けて
現在フロントエンドはモックデータが表示されています。
バックエンドはSupabaseのDBを参照しているので、フロントエンドとバックエンドを繋ぎ合わせて動作確認します。
TodoコンポーネントのAPIを以下のように修正します。

<details>
<summary>Todoコンポーネント</summary>
<div>

```tsx: src/components/pages/Todo/index.tsx
"use client"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { CardContent, Card } from "@/components/ui/card"
import { ChangeEvent, useEffect, useState } from "react"
import { Check, PencilLine, Plus, RefreshCcw, Trash } from "lucide-react"

type Task = {
    /**
     * Task ID
     */
    id: string

    /**
     * Task Title
     */
    label: string
}


export default function Index() {
    const [refetch, setRefetch] = useState(true)
    const [addText, setAddText] = useState("")
    const [updateText, setUpdateText] = useState("")
    const [isUpdateMode, setIsUpdateMode] = useState(false)
    const [taskId, setTaskId] = useState("")
    const [tasks, setTasks] = useState<Task[]>([])

    useEffect(() => {
        if (!refetch) return
        (async () => {
            const data = await getTasks()
            setTasks(data)
            setRefetch(false)
        })()
    }, [refetch, setTasks])

    /**
     * get tasks
     * @returns 
     */
    async function getTasks() {
        const data = await fetch('/api/tasks/get')
        return data.json()
    }

    /**
     * refresh all state
     */
    function refreshAll() {
        setTasks([])
        setAddText("")
        setUpdateText("")
        setIsUpdateMode(false)
        setTaskId("")
        setRefetch(true)
    }

    /**
     * handle Update Mode
     * 
     * @param taskId 
     * @param taskText 
     */
    function handleUpdateMode(taskId: string, taskText: string) {
        setTaskId(taskId)
        setUpdateText(taskText)
        setIsUpdateMode(!isUpdateMode)
    }

    /**
     * handle Update Task Text
     * @param element 
     */
    function handleUpdateText(element: ChangeEvent<HTMLInputElement>) {
        setUpdateText(element.target.value)
    }

    /**
     * execute "Add" Task
     */
    async function addTask() {
        const res = await fetch("/api/task/create", {
            method: "POST",
            body: JSON.stringify({
                label: addText
            }),
        })
        const json = await res.json()
        refreshAll()
    }

    /**
     * execute "Update" Task
     * @param taskId 
     */
    async function updateTask(taskId: string) {
        const res = await fetch("/api/task/update", {
            method: "PUT",
            body: JSON.stringify({
                taskId,
                label: updateText
            }),
        })
        setIsUpdateMode(!isUpdateMode)
        setTaskId("")
        setUpdateText("")
        setRefetch(true)
    }

    /**
     * execute "Delete" Task
     * @param taskId 
     */
    async function deleteTask(taskId: string) {
        const res = await fetch("/api/task/delete", {
            method: "DELETE",
            body: JSON.stringify({
                taskId
            }),
        })
        setTaskId("")
        setUpdateText("")
        setRefetch(true)
    }


    return (
        <main className="w-full max-w-2xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
            <div className="flex justify-start items-center">
                <h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">Todo List</h1>
                <div className="ml-2">
                    <RefreshCcw className="cursor-pointer" onClick={refreshAll} />
                </div>
            </div>
            <div className="mt-6 space-y-4" >
                <div className="space-y-1">
                    <Label htmlFor="task">New Task</Label>
                    <Input id="task" placeholder="Enter a new task" value={addText} onChange={(event) => setAddText(event.target.value)} />
                </div>
                <Button type="button" onClick={() => addTask()} disabled={addText.length < 1}>
                    <Plus className="h-5 w-5" />
                    <span className="ml-2">Add task</span>
                </Button>
            </div>

            {
                tasks.map(task => {
                    return (
                        <Card key={task.id} className="my-2">
                            <CardContent className="flex items-center justify-between p-4">
                                <div className="flex items-center gap-2 w-full">
                                    {
                                        (taskId !== task.id) && (
                                            <Label className="text-lg" htmlFor="task-2">
                                                {task.label}
                                            </Label>
                                        )
                                    }
                                    {
                                        (isUpdateMode && taskId === task.id) && (
                                            <div className="w-full">
                                                <Input type="text" id={`edit-${task.id}`} value={updateText} onChange={handleUpdateText} className="w-full border-none text-lg" />
                                            </div>
                                        )
                                    }
                                </div>
                                <div className="flex items-center gap-2">
                                    {
                                        (taskId !== task.id) && (
                                            <Button size="icon" variant="ghost" disabled={isUpdateMode} onClick={() => handleUpdateMode(task.id, task.label)}>
                                                <PencilLine className="h-5 w-5" />
                                                <span className="sr-only">Edit task</span>
                                            </Button>

                                        )
                                    }
                                    {
                                        (isUpdateMode && taskId === task.id) && (
                                            <Button size="icon" variant="ghost" onClick={() => updateTask(task.id)}>
                                                <Check className="h-5 w-5" />
                                                <span className="sr-only">Update task</span>
                                            </Button>
                                        )
                                    }
                                    <Button size="icon" variant="ghost" disabled={isUpdateMode} onClick={() => deleteTask(task.id)}>
                                        <Trash className="h-5 w-5" />
                                        <span className="sr-only">Delete task</span>
                                    </Button>
                                </div>
                            </CardContent>
                        </Card>
                    )
                })
            }
            {
                tasks.length < 1 && <div className="text-center">no data</div>
            }
        </main>
    )
}
```

</div>
</details>

画面を確認して動作に問題がないか確認します。

## 4-1. Auth認証
アプリにAuth認証を追加します。
Supabase Authenticationを利用することでアカウント認証が簡単にできます。

### 認証方法
Supabaseのユーザー認証方法いくつかありますが、今回はそのうちの`OAuth social providers`を使ってGitHubアカウント認証を実装してみます。

https://supabase.com/docs/guides/auth

https://supabase.com/docs/guides/auth/social-login/auth-github

### コールバック URL を取得
GitHubにOAuthアプリを作成するために、SupabaseからCallbackURL`https://<project-ref>.supabase.co/auth/v1/callback`を取得します。

1. [Supabase Projects](https://supabase.com/dashboard/projects)にアクセス
2. `Authentication` 左側のサイドバーのアイコンをクリック
![スクリーンショット 2024-04-30 14.11.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348770/ac1b319a-3e08-955c-f9cf-159ef20a8aa2.png)
3. `Providers`をクリック後、アコーディオンリストからGitHub見つけて、`Callback URL (for OAuth)`のURLをコピーする（後で戻ってくるのでこのページは開いたままにする）

### GitHub上で GitHub OAuth アプリを作成する
1. [GitHub](https://github.com/)にアクセス
2. [Developer Setting > OAuth Apps](https://github.com/settings/developers)にアクセスする
3. `Register a new application`して設定項目を入力、`Authorization callback URL`にコピーしたCallbackURLを入力する。
4. 作成後、`Generate a new client secret`をクリックして、`Client ID` と `Client secret`をコピーする

### GitHub 認証情報を Supabase プロジェクトに入力する

1. 先ほどの`Providers > GitHubアコーディオン`に戻る
2. 「GitHub Enabled」を「ON」する
3. 前の手順で保存したGitHubの`Client ID` と `Client secret`を入力して保存する

### Redirect URLを設定する
1. `Authentication > URL Configuration > Site URL`に`http://localhost:3000`を入力
2. Save をクリック
※ホスティング後にここは再度修正します。 
![スクリーンショット 2024-05-07 21.51.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348770/8f116758-4711-90cb-863c-49ae30c2c0fb.png)


これで認証の設定は完了しました。

### 認証処理を実装する

utilsにセッション処理をまとめてからhookを作成します。

<details>
<summary>セッション処理</summary>
<div>

authの認証情報を取得するためにsupabaseのモジュールを利用します。
```:command
$ npm i @supabase/supabase-js @supabase/auth-helpers-nextjs
```
また、自身で作成したSupabaseプロジェクトのURLとAPIキーが必要になるので、`.env`にそれらを記述します。
（Next.jsではクライアント側で`.env`を利用するときは定数の頭に`NEXT_PUBLIC_`をつける必要があります。）
※自身のProjectURLとProjectAPIキーは、左メニュー「歯車(Settings)」をクリック後、「CONFIGURATION > API」で確認できます。

```:.env
SUPABASE_DB_URI=
NEXT_PUBLIC_SUPABASE_PROJECT_URL= ←追記
NEXT_PUBLIC_SUPABASE_PROJECT_API_KEY= ←追記
```
※`.env`の設定後、再度`npm run dev`実行

諸々の準備ができたのでセッション処理を実装します。
`auth.signInWithOAuth()`がセッション情報を Local Storage に保存し、`auth.signOut()`が保存したセッション情報を削除します。このセッション情報の有無で認証を管理します。


```tsx: app/src/lib/utils.ts
import { Provider } from "@supabase/supabase-js";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { createPagesBrowserClient } from "@supabase/auth-helpers-nextjs";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

/**
 * create supabase client
 * 
 * @returns 
 */
export const createSupabaseClient = () => {
  const SupabaseClient = {
    instance: createPagesBrowserClient({
      supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_PROJECT_URL,
      supabaseKey: process.env.NEXT_PUBLIC_SUPABASE_PROJECT_API_KEY as string
    }),
  };
  Object.freeze(SupabaseClient);
  return SupabaseClient.instance
}

/**
 * oauth With SignIn
 * 
 * create session at local Storage
 */
export const oauthWithSignIn = async (provider: Provider) => {
  const supabase = createSupabaseClient()
  await supabase.auth.signInWithOAuth({ provider })
}

/**
 * oauth With SignOut
 * 
 * delete session at local Storage
 */
export const oauthWithSignOut = async () => {
  const supabase = createSupabaseClient()
  await supabase.auth.signOut()
}
```
</div>
</details>

次に hook を作成します。
`useSession`はユーザーのセッション情報を、`useUser`はユーザー情報を取得します。

<details>
<summary>useSession.tsx</summary>
<div>

```tsx: app/src/hooks/useSession.tsx
import { createSupabaseClient } from "@/lib/utils"
import { AuthError, Session, createClient } from "@supabase/supabase-js"
import React, { useEffect } from "react"

export default function useSession() {
    const [session, setSession] = React.useState<Session | null>(null)
    const supabase = createSupabaseClient()
    useEffect(() => {
        (async () => {
            const result = await supabase.auth.getSession()
            if (!result) return
            const { data: { session }, error } = result
            if (error) {
                console.error(error)
                return
            }
            setSession(session)
        })()
    }, [])
    if (!session) return
    return session
}
```
</div>
</details>

<details>
<summary>useUser.tsx</summary>
<div>

```tsx: app/src/hooks/useUser.tsx
import { createSupabaseClient } from "@/lib/utils"
import { AuthError, Session, User, createClient } from "@supabase/supabase-js"
import React, { useEffect } from "react"


export default function useUser() {
    const [user, setUser] = React.useState<User | null>(null)
    const supabase = createSupabaseClient()
    useEffect(() => {
        (async () => {
            const result = await supabase.auth.getUser()
            if (!result) return
            const { data: { user }, error } = result
            if (error) {
                console.error(error)
                return
            }
            setUser(user)
        })()
    }, [])
    if (!user) return
    return user
}
```
</div>
</details>

### 認証画面を実装する
認証画面の実装に移ります。
Next.js v14 は`app/`のディレクトリパスがそのままURLのディレクトリパスになり、pages.tsxがindex.tsxの役割を担います。
先ほど実装したTodo機能はトップ画面での処理でしたが、これを`/todo`に移動させ、トップでは認証処理を行うことにします。


<details>
<summary>Todoの修正</summary>
<div>

ログインしたユーザー情報を表示するためのコンポーネントを追加します。
以下を実行後Todoコンポーネントを修正します。

```
$ npx shadcn-ui@latest add dropdown-menu
```

```tsx: app/src/components/pages/Todo/index.tsx
"use client"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { CardContent, Card } from "@/components/ui/card"
import { ChangeEvent, useCallback, useEffect, useState } from "react"
import { Check, PencilLine, Plus, RefreshCcw, Trash, User } from "lucide-react"
import { oauthWithSignOut } from "@/lib/utils"
import { useRouter } from "next/navigation"
import useUser from "@/hooks/useUser"
import {
    DropdownMenu,
    DropdownMenuContent,
    DropdownMenuItem,
    DropdownMenuLabel,
    DropdownMenuSeparator,
    DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

type Task = {
    /**
     * Task ID
     */
    id: string

    /**
     * Task Title
     */
    label: string
}


export default function Index() {
    const [refetch, setRefetch] = useState(true)
    const [addText, setAddText] = useState("")
    const [updateText, setUpdateText] = useState("")
    const [isUpdateMode, setIsUpdateMode] = useState(false)
    const [taskId, setTaskId] = useState("")
    const [tasks, setTasks] = useState<Task[]>([])
    const router = useRouter()
    const user = useUser()

    useEffect(() => {
        if (!refetch) return
        (async () => {
            const data = await getTasks()
            setTasks(data)
            setRefetch(false)
        })()
    }, [refetch, setTasks])

    /**
     * get tasks
     * @returns 
     */
    async function getTasks() {
        const data = await fetch('/api/tasks/get')
        return data.json()
    }

    /**
     * refresh all state
     */
    function refreshAll() {
        setTasks([])
        setAddText("")
        setUpdateText("")
        setIsUpdateMode(false)
        setTaskId("")
        setRefetch(true)
    }

    /**
     * handle Update Mode
     * 
     * @param taskId 
     * @param taskText 
     */
    function handleUpdateMode(taskId: string, taskText: string) {
        setTaskId(taskId)
        setUpdateText(taskText)
        setIsUpdateMode(!isUpdateMode)
    }

    /**
     * handle Update Task Text
     * @param element 
     */
    function handleUpdateText(element: ChangeEvent<HTMLInputElement>) {
        setUpdateText(element.target.value)
    }

    /**
     * execute "Add" Task
     */
    async function addTask() {
        const res = await fetch("/api/task/create", {
            method: "POST",
            body: JSON.stringify({
                label: addText
            }),
        })
        const json = await res.json()
        refreshAll()
    }

    /**
     * execute "Update" Task
     * @param taskId 
     */
    async function updateTask(taskId: string) {
        const res = await fetch("/api/task/update", {
            method: "PUT",
            body: JSON.stringify({
                taskId,
                label: updateText
            }),
        })
        setIsUpdateMode(!isUpdateMode)
        setTaskId("")
        setUpdateText("")
        setRefetch(true)
    }

    /**
     * execute "Delete" Task
     * @param taskId 
     */
    async function deleteTask(taskId: string) {
        const res = await fetch("/api/task/delete", {
            method: "DELETE",
            body: JSON.stringify({
                taskId
            }),
        })
        setTaskId("")
        setUpdateText("")
        setRefetch(true)
    }

    /**
     * sign out
     */
    const signOut = useCallback(async () => {
        await oauthWithSignOut()
        router.push("/")
    }, [router])


    return (
        <main className="w-full max-w-2xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
            <div className="flex justify-between items-center">
                <div className="flex justify-between items-center">
                    <h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">Todo List</h1>
                    <div className="ml-2">
                        <RefreshCcw className="cursor-pointer" onClick={refreshAll} />
                    </div>
                </div>
                <div>
                    <DropdownMenu>
                        <DropdownMenuTrigger>
                            <User className="cursor-pointer" />
                        </DropdownMenuTrigger>
                        <DropdownMenuContent>
                            <DropdownMenuLabel>{user?.user_metadata.preferred_username}</DropdownMenuLabel>
                            <DropdownMenuSeparator />
                            <DropdownMenuItem onClick={() => signOut()}>Sign Out</DropdownMenuItem>
                        </DropdownMenuContent>
                    </DropdownMenu>
                </div>
            </div>
            <div className="mt-6 space-y-4" >
                <div className="space-y-1">
                    <Label htmlFor="task">New Task</Label>
                    <Input id="task" placeholder="Enter a new task" value={addText} onChange={(event) => setAddText(event.target.value)} />
                </div>
                <Button type="button" onClick={() => addTask()} disabled={addText.length < 1}>
                    <Plus className="h-5 w-5" />
                    <span className="ml-2">Add task</span>
                </Button>
            </div>

            {
                tasks.map(task => {
                    return (
                        <Card key={task.id} className="my-2">
                            <CardContent className="flex items-center justify-between p-4">
                                <div className="flex items-center gap-2 w-full">
                                    {
                                        (taskId !== task.id) && (
                                            <Label className="text-lg" htmlFor="task-2">
                                                {task.label}
                                            </Label>
                                        )
                                    }
                                    {
                                        (isUpdateMode && taskId === task.id) && (
                                            <div className="w-full">
                                                <Input type="text" id={`edit-${task.id}`} value={updateText} onChange={handleUpdateText} className="w-full border-none text-lg" />
                                            </div>
                                        )
                                    }
                                </div>
                                <div className="flex items-center gap-2">
                                    {
                                        (taskId !== task.id) && (
                                            <Button size="icon" variant="ghost" disabled={isUpdateMode} onClick={() => handleUpdateMode(task.id, task.label)}>
                                                <PencilLine className="h-5 w-5" />
                                                <span className="sr-only">Edit task</span>
                                            </Button>

                                        )
                                    }
                                    {
                                        (isUpdateMode && taskId === task.id) && (
                                            <Button size="icon" variant="ghost" onClick={() => updateTask(task.id)}>
                                                <Check className="h-5 w-5" />
                                                <span className="sr-only">Update task</span>
                                            </Button>
                                        )
                                    }
                                    <Button size="icon" variant="ghost" disabled={isUpdateMode} onClick={() => deleteTask(task.id)}>
                                        <Trash className="h-5 w-5" />
                                        <span className="sr-only">Delete task</span>
                                    </Button>
                                </div>
                            </CardContent>
                        </Card>
                    )
                })
            }
            {
                tasks.length < 1 && <div className="text-center">no data</div>
            }
        </main>
    )
}
```

修正後、`/todo`にコンポーネントを表示できるように以下のディレクトに配置します。
```tsx: app/src/app/todo/page.tsx
"use client"
import Todo from "@/components/pages/Todo"
export default function Index() {
    return <Todo />
}
```

これでTodoコンポーネントの修正は以上になります。
</div>
</details>


<details>
<summary>Auth認証</summary>
<div>
トップディレクトリにAuthコンポーネントを配置し、ログイン処理を実装します。
先にAuthコンポーネントを実装します。（といってもボタンだけですが）

```tsx: app/src/components/pages/Auth/index.tsx
"use client"

import * as React from "react"
import { Button } from "@/components/ui/button"
import { Icons } from "@/lib/icons"
import { oauthWithSignIn } from "@/lib/utils"
import { Provider } from "@supabase/supabase-js"

type OAuthProviders = "github"

// 認証可能なプロバイダーの一覧
const oauthProviders = [
  { name: "GitHub", provider: "github", icon: "github" },
] satisfies {
  name: string
  icon: keyof typeof Icons,
  provider: OAuthProviders
}[]


export default function Index() {

  /**
   * sign in with provider
   */
  const signIn = React.useCallback(async (provider: Provider) => {
    await oauthWithSignIn(provider)
  }, [])


  return (
    <div className="h-screen flex justify-center items-center">
      {oauthProviders.map((provider) => {
        const Icon = Icons[provider.icon]

        return <div key={provider.provider}>
          <Button onClick={() => signIn(provider.provider)}>
            <Icon className="mr-2 h-4 w-4" aria-hidden="true" />
            <span>
              SignIn with {provider.name}
            </span>
          </Button>
        </div>
      })}
    </div>
  )
}
```

次にトップディレクトリを以下のようのに修正します。

```tsx: app/src/app/page.tsx
"use client"
import useSession from "@/hooks/useSession";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import Auth from "@/components/pages/Auth";

export default function Home() {
  const session = useSession()
  const router = useRouter()
  useEffect(() => {
    if (session) {
      router.push("/todo")
    }
  }, [session, router])
  return <Auth />
}
```
今回はローディング実装を含めていませんが、トップディレクトリでは常にセッション情報を見て、あれば`/todo`へ、なければ認証画面を表示させます。

</div>
</details>

### タスクとユーザーを紐づける
ログインしたユーザー情報とタスク情報を紐づけます。

まずDBスキーマは編集するところからはじめます。
前述の`useUser`から取得した`user`にはSupabase Authentication の UID `user.id` があります。今回はこれを利用してタスクとユーザーを紐づけます。

```diff_tsx: app/src/db/schema.ts
/**
 * テーブル: todos
 */
export const todos = pgTable("todos", {
    id: serial("id").primaryKey(),
+   uid: varchar('uid', { length: 256 }).notNull(),
    label: text("label").notNull(),
    created_at: timestamp("created_at").notNull().defaultNow(),
    updated_at: timestamp("updated_at").notNull().defaultNow(),
})
```

変更後、`npm run db:generate`と`npm run db:push`を実行します。
（db:push実行後に「前のデータが失われますが良いですか？」と聞かれます。今DBにあるデータは特にユーザーと紐づけていないのでYesを選択します）

```command:command
$ npm run db:generate

> app@0.1.0 db:generate
> npx drizzle-kit generate:pg --config drizzle.config.ts

drizzle-kit: v0.20.14
drizzle-orm: v0.29.3

Reading config file '/workspaces/next-drizzle-supabase/app/drizzle.config.ts'
1 tables
todos 5 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ src/db/migrations/0001_cuddly_vector.sql 🚀




$ npm run db:push

> app@0.1.0 db:push
> npx drizzle-kit push:pg --config drizzle.config.ts

drizzle-kit: v0.20.14
drizzle-orm: v0.29.3

Custom config path was provided, using 'drizzle.config.ts'
Reading config file '/workspaces/next-drizzle-supabase/app/drizzle.config.ts'
 Warning  Found data-loss statements:
· You're about to add not-null uid column without default value, which contains 3 items

THIS ACTION WILL CAUSE DATA LOSS AND CANNOT BE REVERTED

Do you still want to push changes?
  No, abort
❯ Yes, I want to truncate 1 table
```

DBスキーマを更新したのであとはuidに関連するAPIとTodoコンポーネントの修正をします。

<details>
<summary>バックエンドAPIの修正</summary>
<div>
headersの authentication に 認証されたユーザーIDがある想定にします。
関連するタスクのCRUD処理を修正します。

```tsx: app/src/pages/api/task/create/index.ts
import { NextApiRequest, NextApiResponse } from 'next';
import db from "@/db"
import { todos } from "@/db/schema"

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    const authorization = req.headers['authorization'];
    if (!authorization) return
    const body = await req.body
    const { label } = JSON.parse(body)

    try {
        const todo = await db.insert(todos).values({ label, uid: authorization }).returning()
        return res.status(200).json(todo)
    } catch (error: any) {
        console.log(error)
        throw new Error(error)
    }

};
```

```tsx: app/src/pages/api/task/delete/index.ts
import { NextApiRequest, NextApiResponse } from 'next';
import db from "@/db"
import { todos } from "@/db/schema"
import { eq, and } from 'drizzle-orm';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    const authorization = req.headers['authorization'];
    if (!authorization) return
    const body = await req.body
    const { taskId } = JSON.parse(body)

    try {
        const todo = await db.delete(todos)
            .where(and(
                eq(todos.id, taskId),
                eq(todos.uid, authorization)
            ))
            .returning()
        return res.status(200).json(todo)
    } catch (error: any) {
        console.log(error)
        throw new Error(error)
    }
};
```

```tsx: app/src/pages/api/task/update/index.ts
import { NextApiRequest, NextApiResponse } from 'next';
import db from "@/db"
import { todos } from "@/db/schema"
import { eq, and } from 'drizzle-orm';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    const authorization = req.headers['authorization'];
    if (!authorization) return
    const body = await req.body
    const { taskId, label } = JSON.parse(body)

    try {
        const todo = await db.update(todos).set({ label })
            .where(and(
                eq(todos.id, taskId),
                eq(todos.uid, authorization)
            ))
            .returning()
        return res.status(200).json(todo)
    } catch (error: any) {
        console.log(error)
        throw new Error(error)
    }
};
```

```tsx: app/src/pages/api/tasks/get/index.ts
import { NextApiRequest, NextApiResponse } from 'next';
import db from "@/db"
import { SelectTodo, todos } from "@/db/schema"
import { asc, desc, eq, Equal } from 'drizzle-orm';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const authorization = req.headers['authorization'];
  if (!authorization) return
  try {
    const selectTodos: SelectTodo[] = await db.select().from(todos)
      .where(eq(todos.uid, authorization))
      .limit(100)
      .orderBy(desc(todos.id))

    if (selectTodos.length < 1) return []

    return res.status(200).json(selectTodos)
  } catch (error: any) {
    console.log(error)
    throw new Error(error)
  }
};
```

</div>
</details>


<details>
<summary>Todoコンポーネント</summary>
<div>
主な修正は認証IDをheadersに詰めてそれぞれのAPIに付与することです。

```tsx: src/components/pages/Todo/index.tsx
"use client"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { CardContent, Card } from "@/components/ui/card"
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from "react"
import { Check, PencilLine, Plus, RefreshCcw, Trash, User } from "lucide-react"
import { oauthWithSignOut } from "@/lib/utils"
import { useRouter } from "next/navigation"
import useUser from "@/hooks/useUser"
import {
    DropdownMenu,
    DropdownMenuContent,
    DropdownMenuItem,
    DropdownMenuLabel,
    DropdownMenuSeparator,
    DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

type Task = {
    /**
     * Task ID
     */
    id: string

    /**
     * Task Title
     */
    label: string
}


export default function Index() {
    const [refetch, setRefetch] = useState(true)
    const [addText, setAddText] = useState("")
    const [updateText, setUpdateText] = useState("")
    const [isUpdateMode, setIsUpdateMode] = useState(false)
    const [taskId, setTaskId] = useState("")
    const [tasks, setTasks] = useState<Task[]>([])
    const router = useRouter()
    const user = useUser()

    const headers = useMemo(() => {
        const headers = new Headers();
        headers.append("authorization", user?.id || '');
        return headers
    }, [user?.id])

    /**
     * get tasks
     * @returns 
     */
    const getTasks = useCallback(async () => {
        const data = await fetch('/api/tasks/get', {
            headers
        })
        return data.json()
    }, [headers])

    /**
     * refresh all state
     */
    const refreshAll = useCallback(() => {
        setTasks([])
        setAddText("")
        setUpdateText("")
        setIsUpdateMode(false)
        setTaskId("")
        setRefetch(true)
    }, [])

    /**
     * handle Update Mode
     * 
     * @param taskId 
     * @param taskText 
     */
    function handleUpdateMode(taskId: string, taskText: string) {
        setTaskId(taskId)
        setUpdateText(taskText)
        setIsUpdateMode(!isUpdateMode)
    }

    /**
     * handle Update Task Text
     * @param element 
     */
    function handleUpdateText(element: ChangeEvent<HTMLInputElement>) {
        setUpdateText(element.target.value)
    }

    /**
     * execute "Add" Task
     */
    const addTask = useCallback(async () => {
        const res = await fetch("/api/task/create", {
            method: "POST",
            headers,
            body: JSON.stringify({
                label: addText
            }),
        })
        const json = await res.json()
        refreshAll()
    }, [headers, refreshAll, addText])

    /**
     * execute "Update" Task
     * @param taskId 
     */
    const updateTask = useCallback(async (taskId: string) => {
        const res = await fetch("/api/task/update", {
            method: "PUT",
            headers,
            body: JSON.stringify({
                taskId,
                label: updateText
            }),
        })
        setIsUpdateMode(!isUpdateMode)
        setTaskId("")
        setUpdateText("")
        setRefetch(true)
    }, [headers, isUpdateMode, updateText])

    /**
     * execute "Delete" Task
     * @param taskId 
     */
    const deleteTask = useCallback(async (taskId: string) => {
        const res = await fetch("/api/task/delete", {
            method: "DELETE",
            headers,
            body: JSON.stringify({
                taskId
            }),
        })
        setTaskId("")
        setUpdateText("")
        setRefetch(true)
    }, [headers])

    /**
     * sign out
     */
    const signOut = useCallback(async () => {
        await oauthWithSignOut()
        router.push("/")
    }, [router])


    useEffect(() => {
        if (!refetch) return
        if(!user?.id) return
        (async () => {
            const data = await getTasks()
            setTasks(data)
            setRefetch(false)
        })()
    }, [refetch, setTasks, getTasks,user?.id])


    return (
        <main className="w-full max-w-2xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
            <div className="flex justify-between items-center">
                <div className="flex justify-between items-center">
                    <h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">Todo List</h1>
                    <div className="ml-2">
                        <RefreshCcw className="cursor-pointer" onClick={refreshAll} />
                    </div>
                </div>
                <div>
                    <DropdownMenu>
                        <DropdownMenuTrigger>
                            <User className="cursor-pointer" />
                        </DropdownMenuTrigger>
                        <DropdownMenuContent>
                            <DropdownMenuLabel>{user?.user_metadata.preferred_username}</DropdownMenuLabel>
                            <DropdownMenuSeparator />
                            <DropdownMenuItem onClick={() => signOut()}>Sign Out</DropdownMenuItem>
                        </DropdownMenuContent>
                    </DropdownMenu>
                </div>
            </div>
            <div className="mt-6 space-y-4" >
                <div className="space-y-1">
                    <Label htmlFor="task">New Task</Label>
                    <Input id="task" placeholder="Enter a new task" disabled={!user?.id} value={addText} onChange={(event) => setAddText(event.target.value)} />
                </div>
                <Button type="button" onClick={() => addTask()} disabled={addText.length < 1 || !user?.id}>
                    <Plus className="h-5 w-5" />
                    <span className="ml-2">Add task</span>
                </Button>
            </div>

            {
                tasks.map(task => {
                    return (
                        <Card key={task.id} className="my-2">
                            <CardContent className="flex items-center justify-between p-4">
                                <div className="flex items-center gap-2 w-full">
                                    {
                                        (taskId !== task.id) && (
                                            <Label className="text-lg" htmlFor="task-2">
                                                {task.label}
                                            </Label>
                                        )
                                    }
                                    {
                                        (isUpdateMode && taskId === task.id) && (
                                            <div className="w-full">
                                                <Input type="text" id={`edit-${task.id}`} value={updateText} onChange={handleUpdateText} className="w-full border-none text-lg" />
                                            </div>
                                        )
                                    }
                                </div>
                                <div className="flex items-center gap-2">
                                    {
                                        (taskId !== task.id) && (
                                            <Button size="icon" variant="ghost" disabled={isUpdateMode} onClick={() => handleUpdateMode(task.id, task.label)}>
                                                <PencilLine className="h-5 w-5" />
                                                <span className="sr-only">Edit task</span>
                                            </Button>

                                        )
                                    }
                                    {
                                        (isUpdateMode && taskId === task.id) && (
                                            <Button size="icon" variant="ghost" onClick={() => updateTask(task.id)}>
                                                <Check className="h-5 w-5" />
                                                <span className="sr-only">Update task</span>
                                            </Button>
                                        )
                                    }
                                    <Button size="icon" variant="ghost" disabled={isUpdateMode} onClick={() => deleteTask(task.id)}>
                                        <Trash className="h-5 w-5" />
                                        <span className="sr-only">Delete task</span>
                                    </Button>
                                </div>
                            </CardContent>
                        </Card>
                    )
                })
            }
            {
                tasks.length < 1 && <div className="text-center">no data</div>
            }
        </main>
    )
}
```
</div>
</details>


## 4-2. ホスティング
ここまでで認証付きTodoアプリを実装できました。
ここでは作成したアプリを公開する方法を紹介します。
Vercel を使ってアプリをデプロイします。

### Vercel 設定
1. [Vercel > サインイン](https://vercel.com/signup)にアクセス
2. Hobbyを選択、アカウント名は任意で。「Continue with GitHub」を選択
3. 「Import Git Repository」 で リポジトリを選択
4. 諸々のビルド設定を行う（設定例以下画像参照
5. 設定完了後、「Deploy」をクリック

https://vercel.com/signup

##### 設定例
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348770/fa92ed87-1d4e-7f1d-4600-68838a0a938d.png)



デプロイ完了後、「Congratulations！」が表示されれば無事デプロイ完了です。
「Add Domain」のリンクをクリックして、`[project name].vercel.app`を表示し、作成したアプリが表示できているか確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348770/ecded646-3732-aa20-ad8f-dafb37a63e36.png)

### Supabase > Site URL を変更
開発中に`http://localhost:3000`で指定した`Supabase Authentication > Site URL`を Vercel で払い出されたサイトURLに変更します。
公開ドメインにアクセスしてアプリが機能していれば完成になります。
以上でアプリ開発は終了です。
お疲れ様でした。


# まとめ
今回は勢いのあるサービスを合わせてお手軽にアプリを構築しました。
他にもいろんなサービスがあって組み合わせもさまざまですが、１つずつ引き出しを増やして良い選択ができるようになりたいですね。

# 参考リンク

- Next.js

https://nextjs.org/

https://zenn.dev/a_da_chi/articles/6235ef4b317368

- Drizzle

https://orm.drizzle.team/

https://qiita.com/masakinihirota/items/ef6d76f37d15c5cff5fb

- Supabase

https://supabase.com/

- Vercel

https://vercel.com/docs