最近流行りの技術やサービスを組み合わせて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
デモ
手順
- はじめに
- 開発環境の構築
2-1. コンテナ定義
2-2. リポジトリの説明
2-3. アカウント作成 - アプリ開発
3-1. 環境セットアップ
3-2. フロントエンド
3-3. バックエンド - デプロイに向けて
4-1. Auth認証
4-2. ホスティング - まとめ
1. はじめに
使用する各サービスの説明は細かく書いていません。
詳しく知りたい方は都度リンクを貼っているので参照してください。
今回作成するアプリのソースコードは以下のGitHubリポジトリから確認できます。
(以下ハンズオンはGithubリポジトリを作成済みという前提で進めます)
2. 開発環境の構築
2-1. コンテナ定義
開発環境を構築します。
VSCode > Remote Containerを利用して構築します。
gith
.devcontainer
ディレクトリを作成→devcontainer.json
を作成します。
.devcontainer/
- 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 Name
とDatabase Password
を登録します。
プロジェクト作成後左サイドメニューの歯車マークProject Settings
をクリック後、Database
を選択、Database Settings > Connection string > URIタブ
のpostgres://~
から始まる文字列をコピーします。この時、[YOUR-PASSWORD]という箇所を先ほど設定したDatabase Password
に置き換えて控えておきます。
開発アプリが接続するDB URIの設定が完了しました。
これでDB接続に必要な設定は終了です。
3. アプリ開発
3-1. 環境セットアップ
Next.jsをインストールします。
コンテナを立ち上げると/workspaces/next-drizzle-supabase|[your repository name]
にいるのでそこで以下のコマンドを実行します。
$ 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 の初期画面が表示されていることを確認します。
3-2. フロントエンド
これからフロントエンドを実装します。
Next.js をインストールしたときにしれっとTailwind CSS
も合わせてセットアップしています。スタイリングはこちらに任せることにします。
また、コンポーネントを一から作るのはだるいので、世に溢れるコンポーネントサービスを使うことにします。
今回は昨今流行りのshadcn/ui
を使ってみます。
なんですかそれ?
従来のコンポーネントライブラリと何が違うの?
という点に関しては過去に自身で記事にしたので参考にしてみて下さい。
脱線しましたが、UIの部分はほぼshadcn/ui
に頼りっぱなしで進めることにします。
shadcn-ui@latest init
した後、必要なコンポーネントを先にインストールします。
$ 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
を以下のように修正します。
import Todo from "@/components/pages/Todo"
export default function Home() {
return (
<Todo />
)
}
次に Todoコンポーネントを配置します。
.
├── pages
│ └── Todo
│ └── index.tsx
└── ui
├── button.tsx
├── card.tsx
├── input.tsx
└── label.tsx
Todoコンポーネント
useEffect() > getTasks()
で一旦想定するデータ(Mock)を返す想定にします。
また、ユーザーイベントを function
で定義します。
- フロントエンドで完結する処理は
handle
を関数名の頭につける - それ以外は状態に応じて正しい命名をつける
としています。
"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で画面表示するところまでできたかと思います。
3-3. バックエンド
はじめに
バックエンドAPIを構築する前に、DB接続など諸々を設定します。
Drizzle
をプロジェクトにインストールします。
$ npm i drizzle-orm postgres
$ npm i -D drizzle-kit
インストール後設定ファイルと.env
を作成します。
$ touch drizzle.config.ts .env .env.example
.env
と.env.example
、加えて.gitignore
を以下のように修正します。
SUPABASE_DB_URI=************************* // 冒頭で取得したDatabase URI
SUPABASE_DB_URI=
# local env files
.env*.local
.env // 追記
修正後、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
ディレクトリを作成し、スキーマとマイグレーションファイルを作成します。
$ mkdir db
$ cd db
$ touch index.ts schema.ts migrate.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;
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>
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
を追加して実行します。
"scripts": {
.
.
.
"db:generate": "npx drizzle-kit generate:pg --config drizzle.config.ts",
"db:push": "npx drizzle-kit push:pg --config drizzle.config.ts"
},
$ 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
テーブルが作成されていることが確認できます。
ここまででSupabaseのDBを利用するところまでの設定を終えました。
次にバックエンドAPIの構築を行います。
バックエンドAPI 構築
今回は以下の構成でAPIを組みます。
.
├── task
│ ├── create
│ │ └── index.ts
│ ├── delete
│ │ └── index.ts
│ └── update
│ └── index.ts
└── tasks
└── get
└── index.ts
それぞれのAPIを以下のように作成します。
/api/tasks/get :全てのタスクを取得
全てのタスクを取得するAPIです。
Supabaseにあるデータを取得して、フロントエンドに返す処理を行います。
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です。
新規タスクを作成します。
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のデータを更新します。
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に対して物理削除を行います。
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コンポーネント
"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
を取得します。
- Supabase Projectsにアクセス
-
Authentication
左側のサイドバーのアイコンをクリック
-
Providers
をクリック後、アコーディオンリストからGitHub見つけて、Callback URL (for OAuth)
のURLをコピーする(後で戻ってくるのでこのページは開いたままにする)
GitHub上で GitHub OAuth アプリを作成する
- GitHubにアクセス
- Developer Setting > OAuth Appsにアクセスする
-
Register a new application
して設定項目を入力、Authorization callback URL
にコピーしたCallbackURLを入力する。 - 作成後、
Generate a new client secret
をクリックして、Client ID
とClient secret
をコピーする
GitHub 認証情報を Supabase プロジェクトに入力する
- 先ほどの
Providers > GitHubアコーディオン
に戻る - 「GitHub Enabled」を「ON」する
- 前の手順で保存したGitHubの
Client ID
とClient secret
を入力して保存する
Redirect URLを設定する
-
Authentication > URL Configuration > Site URL
にhttp://localhost:3000
を入力 - Save をクリック
※ホスティング後にここは再度修正します。
これで認証の設定は完了しました。
認証処理を実装する
utilsにセッション処理をまとめてからhookを作成します。
セッション処理
authの認証情報を取得するためにsupabaseのモジュールを利用します。
$ npm i @supabase/supabase-js @supabase/auth-helpers-nextjs
また、自身で作成したSupabaseプロジェクトのURLとAPIキーが必要になるので、.env
にそれらを記述します。
(Next.jsではクライアント側で.env
を利用するときは定数の頭にNEXT_PUBLIC_
をつける必要があります。)
※自身のProjectURLとProjectAPIキーは、左メニュー「歯車(Settings)」をクリック後、「CONFIGURATION > API」で確認できます。
SUPABASE_DB_URI=
NEXT_PUBLIC_SUPABASE_PROJECT_URL= ←追記
NEXT_PUBLIC_SUPABASE_PROJECT_API_KEY= ←追記
※.env
の設定後、再度npm run dev
実行
諸々の準備ができたのでセッション処理を実装します。
auth.signInWithOAuth()
がセッション情報を Local Storage に保存し、auth.signOut()
が保存したセッション情報を削除します。このセッション情報の有無で認証を管理します。
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
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
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
"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
にコンポーネントを表示できるように以下のディレクトに配置します。
"use client"
import Todo from "@/components/pages/Todo"
export default function Index() {
return <Todo />
}
これでTodoコンポーネントの修正は以上になります。
Auth認証
"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>
)
}
次にトップディレクトリを以下のようのに修正します。
"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
があります。今回はこれを利用してタスクとユーザーを紐づけます。
/**
* テーブル: 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を選択します)
$ 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の修正
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)
}
};
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)
}
};
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)
}
};
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コンポーネント
"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 設定
- Vercel > サインインにアクセス
- Hobbyを選択、アカウント名は任意で。「Continue with GitHub」を選択
- 「Import Git Repository」 で リポジトリを選択
- 諸々のビルド設定を行う(設定例以下画像参照
- 設定完了後、「Deploy」をクリック
設定例
デプロイ完了後、「Congratulations!」が表示されれば無事デプロイ完了です。
「Add Domain」のリンクをクリックして、[project name].vercel.app
を表示し、作成したアプリが表示できているか確認します。
Supabase > Site URL を変更
開発中にhttp://localhost:3000
で指定したSupabase Authentication > Site URL
を Vercel で払い出されたサイトURLに変更します。
公開ドメインにアクセスしてアプリが機能していれば完成になります。
以上でアプリ開発は終了です。
お疲れ様でした。
まとめ
今回は勢いのあるサービスを合わせてお手軽にアプリを構築しました。
他にもいろんなサービスがあって組み合わせもさまざまですが、1つずつ引き出しを増やして良い選択ができるようになりたいですね。
参考リンク
- Next.js
- Drizzle
- Supabase
- Vercel