前回、『Next.js』と『Supabase』を利用した認証機能についてご紹介しました。
そちらでもご紹介した通り、Next.jsとSupabase、この2つを組み合わせることで、非常に効率的な開発を行うことが出来ます。
今回はNext.jsとSupabaseを利用して、シンプルなTodoアプリをつくってみましょう!
Todoアプリと言っても、数行で作れるようなタダのチェックボックスではなく、『CRUD(作成・読み出し・更新・削除)』を行えるTodoアプリとなります。
これらはあらゆるアプリの基本となる動作なので、要・不要に関わらず、これからNext.jsを触っていくエンジニアの皆さんは、ぜひチャレンジしてみてください。
事前準備
①:Supabaseプロジェクトの作成
Supabaseにログイン(登録していないければアカウント登録から)し、
トップページの「New Project」を押します。
すると、下記の様な画面が表示されます。
適当なプロジェクト名と、データベースのパスワードを入れて、新しいプロジェクトを作成しましょう。
※Regionは住んでいる地域の近くが良いです。私は日本にしました。
②:Next.jsプロジェクトの作成
任意のディレクトリで、
npx create-next-app -e with-supabase
と打ち込み、
対話的にアプリケーションの名前の設定をして、プロジェクトの作成を行いましょう。
次に環境変数の設定を行います。
作成されたNext.jsプロジェクトの直下に.env.local.exampleファイルがあるので、
このファイルを.env.localにリネームし、
SupabaseのサイトのProject Settings→APIにある、Project URLとAPI Keyをそれぞれ.env.localにコピペしてください。
.env.local
# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=Project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=APIキー
その後、
npm run dev
を実行して、添付のような画面が表示されればプロジェクトの作成成功です。
Supabaseでテーブル作成
①:テーブル作成
今回、Todoアプリを作るにあたり、各タスクを管理するためのテーブルが必要となります。
タスクに必要な情報は
- id
- 更新日時
- タスクのテキスト
なので、これを元にテーブルを作成しましょう。
Supabase内DashboardのTable Editorメニューから、『New Table』ボタンを押します。
すると新規テーブルの設定画面が出るので、添付画像のように設定してください。
今回は認証機能を利用せず、シンプルにTodoアプリを作りたいため、RLSの有効化チェックをOFFにしています。
※本来はONにすべき機能なので、プロダクトとして公開する際は認証機能を実装した上で有効化して安全に利用しましょう。
これでテーブルの作成はできました。
後で確認しやすくするため、2~3個ほどInsertボタンから適当に行を追加しておきましょう。
※添付画像のように追加さえ出来ていればOKです。
②:更新日の更新用トリガー作成
テーブルは出来ましたが、更新日の情報をより便利に扱えるようにするため、行の更新時『更新日』を書き換えるトリガーを作りましょう。
まずは、DashboardのSQL Editorを開きます。
開いたら、SQLのEditor上で下記を入力してください。
create extension if not exists moddatetime schema extensions;
create trigger handle_updated_at before update on tasks
for each row execute procedure moddatetime (updated_at);
こちらは一行目で必要な拡張機能(moddatetime)の追加を、
二行目以降で更新処理が走るたびに、updated_atの値を現在時で変更するトリガーを作るよう記述しています。
入力が終わったら、右下のRunを実行しましょう。
DashboardのDatabaseのTriggersに下記のようなトリガーが追加されていれば作成成功です。
テーブルの準備が終わったので、Next.js側で実装していきましょう。
Next.js側の実装
①:必要なファイルの作成
今回作りたい画面に合わせて、プロジェクトのファイル構成を変更しましょう。
現状のファイル構成が下記のようになっているかと思いますが、まずは不要なファイルを削除し、必要なファイルと入れ替えて行きます。
不要なファイル削除&入れ替え後
削除するファイルは、
- app/auth/callback/route.ts
- app/auth/login/page.tsx
- componentsフォルダの直下すべて
- util/supabase直下すべて
- middleware.ts
になります。
新たに作成するファイルは、
- components/addTask.tsx
- components/editDialog.tsx
- components/getData.tsx
- components/removeDialog.tsx
- components/task.tsx
- components/taskTable.tsx
- utils/supabase/supabase.ts
- types/supabasetype.ts
になります。(一旦ファイルを新規作成するのみで大丈夫です。中身は後で作ります)
多いので一個ずつ画像を見ながら作成してください。
では、各ファイルの中身を作成する前に下準備から行いましょう。
②:下準備
SupabaseのDBとの連携
Next.jsで実装するためにまずはDatabaseの型を生成しましょう。
下記で、Supabase CLI上でログインします。
(対話的に進めていけばログイン出来ます。)
npx supabase login
その後、Databaseの型生成用のコマンドを実行します。
($PROJECT_IDは、SupabaseのDashboardでプロジェクトを選択した際のURLのhttps://supabase.com/dashboard/project/以下の文字列です。)
npx supabase gen types typescript --project-id "$PROJECT_ID" --schema public > types/supabasetype.ts
これを実行すると、supabasetype.tsのファイルの中身が生成されていることが確認出来ます。
utils/supabase/supabase.ts
Databaseにアクセスするため、事前にsupabase.tsの中身を作成します。
先程生成したsupabasetype.tsもここで利用しています。
import { createClient } from '@supabase/supabase-js'
import { Database } from '@/types/supabasetype'
export const supabase = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
.env.localに入力したURLとAPIキーがここで利用されます。
app/globals.css
tailwindcssのインポート以外は全て消しておきます。
@tailwind base;
@tailwind components;
@tailwind utilities;
③:基本ページの作成
Todoアプリとは関係ないですが、大元のレイアウトを作成します。
app/layout.tsx
import './globals.css'
const defaultUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: 'http://localhost:3000'
export const metadata = {
metadataBase: new URL(defaultUrl),
title: 'Next.js and Supabase Starter Kit',
description: 'The fastest way to build apps with Next.js and Supabase',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className="bg-background text-foreground">
<main className="min-h-screen flex flex-col items-center">
{children}
</main>
</body>
</html>
)
}
app/page.tsx
Todoアプリ全体を覆うtaskTable.tsxをここで利用しています。
import TaskTable from "@/components/taskTable";
export default async function Index() {
return (
<div className="flex-1 w-full flex flex-col gap-20 items-center pt-24">
<TaskTable></TaskTable>
</div>
)
}
④:タスクとタスクテーブル
components/task.tsx
タスク一つ一つのUIの定義と、編集・削除ボタンから表示されるモーダルへデータを渡す役目を持っているファイルです。
"use client"
import { useState, Dispatch, SetStateAction, ReactElement } from 'react';
import EditDialog from './editDialog';
import RemoveDialog from './removeDialog';
export default function Task(props: { id: number, text: string, update_at: string, taskList: Dispatch<SetStateAction<Array<ReactElement>>> }) {
const [showEditModal, setShowEditModal] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
const id = props.id
const text = props.text
const update_at = props.update_at
let last_update = new Date(update_at)
return (
<>
<div>
<p className="text-gray-600 break-all">
{text}
</p>
<p className="text-xs text-gray-400">最終更新日時:{last_update.toLocaleString("ja-JP")}</p>
</div>
<div className="flex">
<button type="button" className="w-9 text-blue-500 hover:text-blue-600" onClick={() => setShowEditModal(true)}>編集</button>
<button type="button" className="ml-2 w-9 text-red-500 hover:text-red-600" onClick={() => setShowRemoveModal(true)}>削除</button>
</div>
{showEditModal ? (
<EditDialog id={id} taskList={props.taskList} showModal={setShowEditModal}></EditDialog>
) : null}
{showRemoveModal ? (
<RemoveDialog id={id} taskList={props.taskList} showModal={setShowRemoveModal}></RemoveDialog>
) : null}
</>
)
}
編集ボタンと削除ボタンから表示されるモーダルの表示、非表示の状態管理は下記で行っています。
const [showEditModal, setShowEditModal] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
また、各タスクに必要な情報(id、テキスト、更新日時)を親から受け取りUIに流し込んでいます。
const id = props.id
const text = props.text
const update_at = props.update_at
// タイムスタンプを日付型に入れて変換しやすくしている
const last_update = new Date(update_at)
タスクの見た目は以下のようになります。
components/taskTable.tsx
タスクの追加ボタンと、タスク全体をリスト表示するUIを定義します。
"use client"
import AddTask from "./addTask"
import { ReactElement, useState, useEffect } from "react"
import getData from "./getData"
export default function TaskTable() {
const [taskList, setTaskList] = useState<Array<ReactElement>>([])
// 初回のみ実行したいので、第二引数が空のuseEffectでデータ取得
useEffect(() => {
getData(setTaskList)
}, [])
return (
<div className="sm:w-full md:w-1/2 lg:w-1/2 xl:w-1/3 max-w-md mx-auto bg-white rounded-xl shadow-md overflow-hidden">
<div className="p-4">
<h1 className="text-xl font-bold text-gray-800">Todoリスト</h1>
<AddTask taskList={setTaskList}></AddTask>
<ul className="mt-4 divide-y divide-gray-200">
{taskList}
</ul>
</div>
</div>
)
}
下記でタスクの一覧を配列として管理しています。
const [taskList, setTaskList] = useState<Array<ReactElement>>([])
また、タスク一覧の初回表示時のデータ追加を下記のgetDataの実行で行っています。
マウント時に一回だけ実行したいため、第二引数を空の配列にしています。
(この配列に入った値に変更があるたびに実行するような挙動を、本来は作成出来ます)
useEffect(() => {
getData(setTaskList)
}, [])
見た目は下記の様になります。
⑤:データ取得
components/getData.tsx
supabaseのデータベースからのデータ取得のために利用する処理です。
複数箇所(taskTable.tsx、editDialog.tsx, removeDialog.tsx, addTask.tsx)で利用するため、独立した関数にしています。
import { supabase } from "@/utils/supabase/supabase"
import Task from "./task"
import { Dispatch, SetStateAction, ReactElement } from "react"
export default async function getData(
taskList: Dispatch<SetStateAction<Array<ReactElement>>>
) {
const tmpTaskList = []
try {
let { data: tasks, error } = await supabase
.from('tasks')
.select('*')
if (error) {
console.log(error)
}
if (tasks != null) {
for (let index = 0; index < tasks.length; index++) {
tmpTaskList.push(<li className="flex items-center justify-between py-2" key={tasks[index]["id"]}>
<Task taskList={taskList} id={tasks[index]["id"]} text={tasks[index]["text"] ?? ""} update_at={tasks[index]["update_at"] ?? ""}></Task>
</li>)
}
taskList(tmpTaskList)
}
} catch (error) {
console.log(error);
}
}
下記で、tasksテーブルの全データを取得し、
let { data: tasks, error } = await supabase
.from('tasks')
.select('*')
下記で親から渡されたsetTaskListにtasksの全タスクをReactElement化した状態で流し込んでいます。
for (let index = 0; index < tasks.length; index++) {
tmpTaskList.push(<li className="flex items-center justify-between py-2" key={tasks[index]["id"]}>
<Task taskList={taskList} id={tasks[index]["id"]} text={tasks[index]["text"] ?? ""} update_at={tasks[index]["update_at"] ?? ""}></Task>
</li>)
}
taskList(tmpTaskList)
⑥:データ追加
components/addTask.tsx
下記でタスクテーブルにデータを追加する処理と処理を行う部分のUIを定義しています。
"use client"
import { supabase } from "@/utils/supabase/supabase"
import { Dispatch, SetStateAction, ReactElement, useState } from "react"
import getData from "./getData"
export default function AddTask(props: {
taskList: Dispatch<SetStateAction<Array<ReactElement>>>;
}) {
const [text, setText] = useState("");
const onSubmit = async (event: any) => {
event.preventDefault();
try {
let { data, error } = await supabase
.from('tasks')
.insert([
{ text: text }
])
.select()
if (error) {
console.log(error);
}
await getData(props.taskList)
} catch (error) {
console.log(error);
}
};
return (
<form className="mt-4" onSubmit={onSubmit}>
<input type="text" className="w-full border border-gray-300 rounded-lg px-2 py-1" placeholder="新しいタスクを入力してください" required value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit" className="mt-2 w-full bg-blue-500 hover:bg-blue-600 text-white font-semibold rounded-lg px-4 py-2">追加</button>
</form>
)
}
下記でタスクのテキストを管理しています。
const [text, setText] = useState("");
また、「追加」ボタンを押した際のフォーム送信処理で、
データの追加とその後のデータ取得(getDataによるTodo一覧のUI更新)を行っています。
let { data, error } = await supabase
.from('tasks')
.insert([
{ text: text }
])
.select()
if (error) {
console.log(error);
}
await getData(props.taskList)
見た目は下記のようになります。
追加ボタンを押すと、タスク一覧に「タスク4」が追加され、Supabase側でTableを見た際もタスクが追加されていることがわかります。
⑦:データ編集
タスクの「編集」ボタンを押したときに表示する編集ダイアログと、Supabaseのデータ更新処理を行うファイルを作成します。
components/editDialog.tsx
components/editDialog.tsx
import { supabase } from "@/utils/supabase/supabase"
import { Dispatch, SetStateAction, ReactElement, useState } from "react"
import getData from "./getData"
export default function EditDialog(props: {
id: number,
showModal: Dispatch<SetStateAction<boolean>>,
taskList: Dispatch<SetStateAction<Array<ReactElement>>>
}) {
const { showModal, taskList } = props;
const [text, setText] = useState("");
const onSubmit = async (event: any) => {
event.preventDefault();
showModal(false);
try {
const { data, error } = await supabase
.from('tasks')
.update({ text: text })
.eq('id', props.id)
.select()
if (error) {
console.log(error);
}
await getData(taskList)
} catch (error) {
console.log(error);
}
};
return (
<div className="overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full h-screen bg-black-rgba pt-28">
<div className="m-auto relative p-4 w-full max-w-md max-h-full">
<div className="relative bg-white rounded-lg shadow">
<div className="flex items-center justify-between p-4 md:p-5 border-b rounded-t">
<h3 className="text-xl font-semibold text-gray-900">
タスクの編集
</h3>
<button
type="button"
className="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center"
data-modal-hide="authentication-modal"
onClick={() => showModal(false)}
>
<svg
className="w-3 h-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span className="sr-only">モーダルを閉じる</span>
</button>
</div>
<div className="p-4 md:p-5">
<form className="space-y-4" onSubmit={onSubmit}>
<div>
<input
type="text"
name="text"
id="text"
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
required
value={text} onChange={(e) => setText(e.target.value)}
/>
</div>
<div>
<button
type="submit"
className="w-full text-white bg-blue-500 hover:bg-blue-600 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
>
保存
</button>
</div>
</form>
</div>
</div>
</div>
</div>
)
}
モーダルの表示ON/OFFを管理するshowModalと、新たなタスクのテキストを管理するsetTextを作成しています。
const { showModal, taskList } = props;
const [text, setText] = useState("");
Supabase側での更新処理は下記のコードで行われており、
データベースのidで更新すべき対象を決定しています。
const { data, error } = await supabase
.from('tasks')
.update({ text: text })
.eq('id', props.id)
.select()
ダイアログの見た目は下記のような形です。
入力して保存を行うと、更新日時(updated_at)が更新され、更新タイミングの現在時になります。(下記は更新後のテーブル。idが古いデータの更新日時が一番最後になっており、更新済みなことがわかる)
⑧:データ削除
最後の機能としてデータの削除を作成します。
components/removeDialog.tsx
import { supabase } from "@/utils/supabase/supabase"
import { Dispatch, SetStateAction, ReactElement } from "react"
import getData from "./getData"
export default function RemoveDialog(props: {
id: number,
showModal: Dispatch<SetStateAction<boolean>>,
taskList: Dispatch<SetStateAction<Array<ReactElement>>>
}) {
const { showModal, taskList } = props;
const onSubmit = async (event: any) => {
event.preventDefault();
showModal(false);
try {
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', props.id)
if (error) {
console.log(error);
}
await getData(taskList)
} catch (error) {
console.log(error);
}
};
return (
<div className="overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full h-screen bg-black-rgba pt-28">
<div className="m-auto relative p-4 w-full max-w-md max-h-full">
<div className="relative bg-white rounded-lg shadow">
<div className="flex items-center justify-between p-4 md:p-5 border-b rounded-t">
<h3 className="text-xl font-semibold text-gray-900">
タスクを削除します。よろしいですか?
</h3>
<button
type="button"
className="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center"
data-modal-hide="authentication-modal"
onClick={() => showModal(false)}
>
<svg
className="w-3 h-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span className="sr-only">モーダルを閉じる</span>
</button>
</div>
<div className="p-4 md:p-5">
<div className="flex">
<form className="w-1/2" onSubmit={onSubmit}>
<button
type="submit"
className="w-full text-white bg-blue-500 hover:bg-blue-600 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
>
タスクを削除
</button>
</form>
<button
className="ml-2 w-1/2 text-white bg-gray-400 hover:bg-gray-500 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
onClick={() => showModal(false)}
>
キャンセル
</button>
</div>
</div>
</div>
</div>
</div>
)
}
モーダルの表示ON/OFFを管理するshowModalを作成しています。
const { showModal, taskList } = props;
Supabase側での削除処理は下記のコードで行われており、
データベースのidで削除すべき対象を決定しています。
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', props.id)
ダイアログの見た目は下記のような形です。
「タスクを削除」ボタンを押すと、Supabase側でも削除が行われます。
before
after
その他
tailwind.config.css
フォームが開かれたときに背景が暗くなるのを作成するために、tailwindのコンフィグファイルにカスタムCSSを作成します。
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
'black-rgba': 'rgba(0, 0, 0, 0.3)',
},
},
},
plugins: [],
}
black-rgbaで定義している部分になります。
こちらでTodoアプリの作成は完了です。
npm run dev
を行い、Supabaseのテーブルを見ながらCRUDの全てがちゃんと行われているかどうか確認してみましょう!
アプリを1個完成させると自信になる
ここまで作成したアプリのコードは、こちらのgithub上で確認することが出来ます。
今回ご紹介したTodoアプリですが、Todoアプリそのものは世の中に五万と存在しており、オリジナルで開発する意味自体はそこまでないかもしれません。
しかしながら、なにか1つアプリを完成させること(それがコード全てが公開されているものであったとしても)によって、エンジニアの経験値は大きく上昇します。
特に今回ご紹介したのは、『CRUD』という、ソフトウェアに要求される4つの基本機能(作成・読み出し・更新・削除)を網羅したアプリとなります。
これらの機能を作ることができれば、それらを発展させてマッチングアプリやSNSアプリ、ECツール等を作ることも可能となるでしょう。
TodoONada株式会社では、今後も様々なアプリの作り方をご紹介しようと思います。
ぜひそちらもご覧いただき、エンジニア技術の向上に役立てていただければ幸いです。
お問合せ&各種リンク
- お問合せ:GoogleForm
- ホームページ:https://libproc.com
- 運営会社:TodoONada株式会社
- Twitter:https://twitter.com/Todoonada_corp
- Instagram:https://www.instagram.com/todoonada_corp/
- Youtube:https://www.youtube.com/@todoonada_corp/
- Tiktok:https://www.tiktok.com/@todoonada_corp
presented by