7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-05-07

最近流行りの技術やサービスを組み合わせて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

手順

  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リポジトリを作成済みという前提で進めます)

2. 開発環境の構築

2-1. コンテナ定義

開発環境を構築します。
VSCode > Remote Containerを利用して構築します。

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

.devcontainer/
    - devcontainer.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. リポジトリの説明

コンテナを立ち上げることで開発環境をセットアップすることができました。
最終的なリポジトリ構成は以下の想定になります。

.
├── 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アカウントがあればサクッとアカウント作成できるのでおすすめです。

これからSupabaseのプロジェクト作成からDBに接続するDatabaseURIの取得まで行います。

プロジェクト作成 & URIの取得

はじめにプロジェクトを作成します。
Create new projectを選択し、Project NameDatabase Passwordを登録します。

image.png

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

スクリーンショット 2024-02-07 19.15.35.png

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

3. アプリ開発

3-1. 環境セットアップ

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

コンテナを立ち上げると/workspaces/next-drizzle-supabase|[your repository name]にいるのでそこで以下のコマンドを実行します。

/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

3-2. フロントエンド

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

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

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

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

shadcn-ui@latest initした後、必要なコンポーネントを先にインストールします。

/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を以下のように修正します。

src/app/pages.tsx
import Todo from "@/components/pages/Todo"

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

次に Todoコンポーネントを配置します。

src/components
.
├── pages
│   └── Todo
│       └── index.tsx
└── ui
    ├── button.tsx
    ├── card.tsx
    ├── input.tsx
    └── label.tsx
Todoコンポーネント

useEffect() > getTasks()で一旦想定するデータ(Mock)を返す想定にします。
また、ユーザーイベントを function で定義します。

  • フロントエンドで完結する処理はhandleを関数名の頭につける
  • それ以外は状態に応じて正しい命名をつける

としています。

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>
    )
}

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

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

image.png

3-3. バックエンド

はじめに

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

/app
$ npm i drizzle-orm postgres
$ npm i -D drizzle-kit

インストール後設定ファイルと.envを作成します。

/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を以下のように記述します。

/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/srcdbディレクトリを作成し、スキーマとマイグレーションファイルを作成します。

/app/src
$ mkdir db
$ cd db
$ touch index.ts schema.ts migrate.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;
/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>
/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.jsonnpm run db:generatenpm run db:pushを追加して実行します。

/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"
},
/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

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

バックエンドAPI 構築

今回は以下の構成でAPIを組みます。

src/pages/api
.
├── task
│   ├── create
│   │   └── index.ts
│   ├── delete
│   │   └── index.ts
│   └── update
│       └── index.ts
└── tasks
    └── get
        └── index.ts

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

/api/tasks/get :全てのタスクを取得

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

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)
      }
};
/api/task/create :タスクの作成

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

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)
    }
};
/api/task/update :タスクの更新

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

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)
    }
};
/api/task/delete :タスクの削除

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

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)
    }
};

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

4. デプロイに向けて

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

Todoコンポーネント
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>
    )
}

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

4-1. Auth認証

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

認証方法

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

コールバック URL を取得

GitHubにOAuthアプリを作成するために、SupabaseからCallbackURLhttps://<project-ref>.supabase.co/auth/v1/callbackを取得します。

  1. Supabase Projectsにアクセス
  2. Authentication 左側のサイドバーのアイコンをクリック
    スクリーンショット 2024-04-30 14.11.19.png
  3. Providersをクリック後、アコーディオンリストからGitHub見つけて、Callback URL (for OAuth)のURLをコピーする(後で戻ってくるのでこのページは開いたままにする)

GitHub上で GitHub OAuth アプリを作成する

  1. GitHubにアクセス
  2. Developer Setting > OAuth Appsにアクセスする
  3. Register a new applicationして設定項目を入力、Authorization callback URLにコピーしたCallbackURLを入力する。
  4. 作成後、Generate a new client secretをクリックして、Client IDClient secretをコピーする

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

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

Redirect URLを設定する

  1. Authentication > URL Configuration > Site URLhttp://localhost:3000を入力
  2. Save をクリック
    ※ホスティング後にここは再度修正します。
    スクリーンショット 2024-05-07 21.51.09.png

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

認証処理を実装する

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

セッション処理

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()が保存したセッション情報を削除します。このセッション情報の有無で認証を管理します。

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

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

useSession.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
}
useUser.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
}

認証画面を実装する

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

Todoの修正

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

$ npx shadcn-ui@latest add dropdown-menu
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にコンポーネントを表示できるように以下のディレクトに配置します。

app/src/app/todo/page.tsx
"use client"
import Todo from "@/components/pages/Todo"
export default function Index() {
    return <Todo />
}

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

Auth認証
トップディレクトリにAuthコンポーネントを配置し、ログイン処理を実装します。 先にAuthコンポーネントを実装します。(といってもボタンだけですが)
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>
  )
}

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

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へ、なければ認証画面を表示させます。

タスクとユーザーを紐づける

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

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

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:generatenpm run db:pushを実行します。
(db:push実行後に「前のデータが失われますが良いですか?」と聞かれます。今DBにあるデータは特にユーザーと紐づけていないのでYesを選択します)

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コンポーネントの修正をします。

バックエンドAPIの修正
headersの authentication に 認証されたユーザーIDがある想定にします。 関連するタスクのCRUD処理を修正します。
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)
    }

};
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)
    }
};
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)
    }
};
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)
  }
};
Todoコンポーネント
主な修正は認証IDをheadersに詰めてそれぞれのAPIに付与することです。
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>
    )
}

4-2. ホスティング

ここまでで認証付きTodoアプリを実装できました。
ここでは作成したアプリを公開する方法を紹介します。
Vercel を使ってアプリをデプロイします。

Vercel 設定

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

設定例

image.png

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

image.png

Supabase > Site URL を変更

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

まとめ

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

参考リンク

  • Next.js

  • Drizzle

  • Supabase

  • Vercel

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?