Next.js + Supabase で ToDo アプリを開発！

# はじめに

こんにちは！
今回は **Next.js (React 19)** と **Supabase** だけでサクッと動く **ToDo アプリ** を作ったので、
セットアップ〜実装ポイントを 1 ページにまとめました。

 **話題のSupabaseを触ってみたい**


という方の参考になれば幸いです 📝

---

# 目次

1. [完成イメージ](#完成イメージ)
2. [技術スタック](#技術スタック)
3. [フォルダ構成](#フォルダ構成)
4. [環境変数を準備](#環境変数を準備)
5. [Supabase プロジェクトを作成](#supabase-プロジェクトを作成)
6. [テーブル todo を作成](#テーブル-todo-を作成)
7. [Next.js 実装](#nextjs-実装)
8. [動かしてみる](#動かしてみる)

---

## 完成イメージ

ローカルで `npm run dev` → ブラウザにアクセスするとこんな感じです。
タスク追加・削除がリアルタイムに反映されます✨
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3836145/a36cb1ff-5000-481b-8b0b-1079dbde5d01.png)


---

## 技術スタック

| 区分     | 採用技術                      | メモ                                   |
| ------ | ------------------------- | ------------------------------------ |
| フロント   | **Next.js 15 / React 19** | App Router & Server Actions          |
| スタイリング | **Tailwind CSS**          | `className` だけで完結                    |
| バックエンド | **Supabase**              | Postgres + Auth + Row Level Security |
| 型      | **TypeScript**            |                                  |


---

## フォルダ構成

```
repo-root/
├─ app/             # Next.js App Router
│  └─ page.tsx      # 1 ページ構成
├─ utils/
│  └─ supabase.ts   # Supabase クライアント
├─ .env.local       # 環境変数
└─ README.md
```

最小構成なので 1 ファイルずつ把握すれば OK です。

---

## 環境変数を準備

`.env.local` に公開 URL / anon key を貼り付けます。

```dotenv
NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```

> **ポイント**
>
> * `NEXT_PUBLIC_` で始まるキーはクライアントに露出するため **漏洩リスクのない anon key** を使います。

---

## Supabase プロジェクトを作成

1. [Supabase Console](https://app.supabase.com/) で新規プロジェクトを作成
2. 「Project Password」にメモを残す
3. 「Settings → API」で **URL / anon key** を取得

数十秒で **PostgreSQL + 認証サーバー + ストレージ** が起動します。便利すぎる…。

---

## テーブル todo を作成

SQL エディタに貼り付けて実行するだけです。

```sql
create table public.todo (
  id    bigint generated by default as identity primary key,
  title text   not null,
  created_at timestamptz default now()
);

-- RLS を有効化（今回は簡略化のため全員読み書き可）
alter table todo enable row level security;
create policy "Public CRUD" on todo
  for all using (true) with check (true);
```

---

## Next.js 実装

### 1. Supabase クライアント

```ts:utils/supabase.ts
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);
```

### 2. ToDo 画面

```tsx:app/page.tsx
"use client";

import { useState, useEffect, FormEvent } from "react";
import { supabase } from "../utils/supabase";

type Todo = { id: number; title: string };

export default function Home() {
  const [tasks, setTasks] = useState<Todo[]>([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);

  // 一覧取得
  const fetchTodos = async () => {
    setLoading(true);
    const { data, error } = await supabase
      .from("todo")
      .select("*")
      .order("id", { ascending: false });
    if (!error && data) setTasks(data);
    setLoading(false);
  };

  useEffect(() => { fetchTodos(); }, []);

  // 追加
  const addTask = async (e: FormEvent) => {
    e.preventDefault();
    if (!input.trim()) return;
    setLoading(true);
    await supabase.from("todo").insert({ title: input.trim() });
    setInput("");
    await fetchTodos();
    setLoading(false);
  };

  // 削除
  const deleteTask = async (id: number) => {
    setLoading(true);
    await supabase.from("todo").delete().eq("id", id);
    await fetchTodos();
    setLoading(false);
  };

  return (
    <div className="flex flex-col items-center min-h-screen p-8 bg-gray-50 dark:bg-gray-900">
      <h1 className="text-3xl font-bold mb-8 mt-8 text-gray-800 dark:text-gray-100">
        ToDo アプリ
      </h1>

      <form onSubmit={addTask} className="flex gap-2 mb-8 w-full max-w-md">
        <input
          className="flex-1 px-4 py-2 rounded border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-800 dark:text-white"
          placeholder="新しいタスクを入力..."
          value={input}
          onChange={e => setInput(e.target.value)}
        />
        <button
          type="submit"
          className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
          disabled={loading}
        >
          追加
        </button>
      </form>

      <ul className="w-full max-w-md space-y-2">
        {loading && <li className="text-gray-400 text-center">読み込み中...</li>}
        {!loading && tasks.length === 0 && (
          <li className="text-gray-400 text-center">タスクがありません</li>
        )}
        {tasks.map(t => (
          <li key={t.id} className="flex justify-between bg-white dark:bg-gray-800 px-4 py-2 rounded shadow">
            <span className="break-all text-gray-800 dark:text-gray-100">{t.title}</span>
            <button
              onClick={() => deleteTask(t.id)}
              className="ml-4 text-red-500 hover:text-red-700 font-bold"
              aria-label="タスクを削除"
              disabled={loading}
            >
              削除
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}
```

---

## 動かしてみる

```bash
# 1. プロジェクト作成
npx create-next-app@latest supabase-todo --ts --tailwind

# 2. 必要パッケージ
cd supabase-todo
npm install @supabase/supabase-js

# 3. 上記コードを貼り付け & .env.local を配置

# 4. 起動
npm run dev
```

`http://localhost:3000` にアクセスしてタスクを追加すると、
即座に Postgres に INSERT → `fetchTodos` で再取得され UI が更新されます。

---