1. はじめに(背景)
⭐ 背景と動機
普段はC# (ASP.NET) を扱う「DB屋」が、モダンなフロントエンド開発 (Next.js + Supabase) に挑戦しました。 そこで直面したのは、 「AIに書かせたコードは動くけれど、保守性は高くないかも」 という現実です。
🤔 抱えていた課題
AIコードの品質: Cursorやv0は確かに「動くコード」を生成しますが、放っておくと page.tsx にロジック・DBアクセス・UI全てを詰め込むクセがあるようです。
設計の不在: 個別のライブラリ(Next.js, Prismaなど)の使い方はあっても、アプリ全体を貫く 「堅牢な設計指針」 を見つけることができませんでした。
結論: DB屋の流儀を持ち込む
DB設計の基本である 「正規化(重複排除)」 と 「責務分離」 をフロントエンドにも適用し、擬似的な MVCパターン で再構成したところ、かなりすっきりしましたのでこの記事で共有します。
AI生成のカオス体験
初期、CursorのAgentモードに任せた結果、何でも屋と化した巨大な page.tsx が爆誕しました。 DB屋の視点で見れば、これは 「1つのテーブルにあらゆるデータを詰め込み、正規化を放棄した状態」 に等しい恐怖でした。
MVC導入後の変化
設計(型枠)を先に決めることで、AIへの指示出しも「Model層のこのメソッドだけ修正して」とピンポイントで行えるようになりました。AIの思考時間やトークン消費も大幅に削減され、開発の生産性が劇的に向上しました。
2.デモアプリのイメージ
3. 技術スタックと選定理由
✅ DBスペシャリストとしてやりたいこと/やりたくないこと
- テーブル定義を絶対に2箇所へ書きたくない 1箇所のみにしたい
- SQLで表現できる複雑なselect文(サブクエリーやjoin, union)も必要なら書けること
- できるだけ直感的にデータアクセスしたい(APIコールでfetchするのはイヤ)
- データアクセスは独立してテストできるようにしたい
✅ Prismaの採用
⭕ Server Actionsでの記述
かなり直感的に記述できるので気に入ってます
export const getMyTodos = async (userId: string): Promise<Todo[]> => {
const todos = await prisma.todo.findMany({
where: {
userId: userId,
},
orderBy: [{ is完了: "asc" }, { createdAt: "asc" }],
});
return todos;
};
❌ API Routes経由での記述
もしこの記法しかなかったら僕はPrismaを使わないです
export const getMyTodos = async (userId: string): Promise<Todo[]> => {
const response = await fetch(`/api/todos?userId=${userId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Failed to fetch todos");
}
return (await response.json()) as Todo[];
};
✅ 採用した技術スタック
🟡 Next.js (App Router):
- サーバーとクライアントの境界を意識できる点が、DB接続周りと相性が良い
🟡 Prisma:
- schema.prisma を SSOT(信頼できる唯一の情報源) にできる。型定義の二重管理を撲滅
- 最初は学習コストがかかるが、DB向けのミドルウエアなので一度学習すればDBが変わっても再利用できる
🟡 Supabase:
- フルマネージドな PostgreSQL として採用
✅ 各種バージョン
| アプリケーション | バージョン |
|---|---|
| next.js | @16.1.2 |
| react | @19.2.3 |
| tailwindcss | @4.1.18 |
| NextAuth.js | @5.0.0-beta.30 |
| bcryptjs | @3.0.3 |
| prisma | @6.2.1 |
| zod | @3.25.76 |
Prismaの最新バージョンは7.2.0 ですが、AWS Amplifyへデプロイするので、安定版の6.2.1を利用しました
認証はNextAuthを利用してます。
こちらの記事にNextAuthの利用方法をまとめてます。
✅ 利用している shadcn UI のコンポーネント
| コンポーネント |
|---|
| button |
| card |
| checkbox |
| input |
| label |
4.最終ディレクトリ構成
prisma/
├── schema.prisma # Prismaのスキーマファイル モデル情報もここに記述
src/
├── middleware.ts # 認証・認可制御 最大の門番
├── lib/
│ ├── auth.ts # NextAuth.js v5 設定(認証の専門家)
│ ├── authenticate.ts # 認証用のアクションサーバー
│ ├── prisma.ts # Prismaをシングルトンで利用する
│ ├── schema.ts # ユーザー入力バリデーション用のzodのスキーマ
│ ├── todo.ts # 【Model】Todoモデルへのリポジトリ
│ ├── user.ts # 【Model】認証用のUserモデルへのリポジトリ
│ └── utils.ts # shadcnが自動作成
├── components/
│ ├── ui/
│ │ └── {...}.tsx # shadcnで利用しているコンポーネント郡
│ ├── AuthForm.tsx # ログインフォーム
│ ├── TodoApp.tsx # Todoアプリ画面のメイン画面
│ ├── TodoItem.tsx # 1行のTodoを表示/操作するためのコンポーネント
│ └── TodoList.tsx # Todoの一覧を表示/操作するためのコンポーネント
└── app/
├── login/
│ └── page.tsx # ログインページ(パブリック)
├── (protected)/ # Route Group(認証必須)
│ ├── action.tsx # 【Controler】Todoアプリメインページのコントローラ
│ └── page.tsx # 【View】Todoアプリメインページ
└── api/auth/[...nextauth]/
└── route.ts # NextAuth APIエンドポイント
コード全文を公開してます
これ以外に環境変数用の .env ファイルがルート直下に必要です。
✅ .env の記述内容
# --- Prisma用 (DB接続) ---
DATABASE_URL="postgresql://postgres:{あなたのSupabase用のURL}.supabase.co:6543/postgres?pgbouncer=true"
DIRECT_URL="postgresql://postgres:{あなたのSupabase用のURL}.supabase.co:5432/postgres"
# --- NextAuth用 (認証シークレット) ---
AUTH_SECRET="{あなたの認証用シークレット}"
AUTH_TRUST_HOST=true
AUTH_URL="http://localhost:3000"
Supabase の Connection String はデフォルトで Direct connectionが表示されますが IPv4 に対応してません。
DATABASE_URL は Transaction poolerを選択してください。
5. MVC( Model View Controller)とは?
✅ Model データとビジネスロジック
- schema.prisma
- データの定義
- src/lib/todo.ts
- CRUDの実体
- DB操作 (Prisma)
✅ View 画面表示
- src/app/page.tsx
- ルートページ
- src/components/*.tsx
- データの表示
- UIイベントの検知
- HTML/CSSの生成
✅ Controller 入力の受付とモデルの制御
- src/app/(protected)/actions.ts
- Viewからの入力を受け取る
- Modelを呼び出す
- revalidatePath でViewに更新を通知
6.Modelの実装
✅ Supabase 側のテーブルスキーマ
-- Users
create table public."Users" (
id uuid not null default gen_random_uuid (),
"user" text not null,
password text not null,
constraint Users_pkey primary key (id),
constraint Users_user_key unique ("user")
) TABLESPACE pg_default;
-- Todos
create table public."Todos" (
id integer generated by default as identity not null,
"userId" uuid not null,
"タスク" text not null,
"is完了" boolean not null default false,
"createdAt" timestamp without time zone not null default now(),
constraint Todos_pkey primary key (id),
constraint Todos_userId_fkey foreign KEY ("userId") references "Users" (id) on delete CASCADE
) TABLESPACE pg_default;
✅ DBからModelの定義を自動生成する
npx prisma db pull
Prismaのインストールで自動生成されるファイル /prisma/schema.prisma に、Model情報が自動生成されます
Prismaは日本語のテーブル名も列名も利用できるのに、 db pull と db push だけは ユビキタス言語に対応していません。
日本語がうまく変換できていない場合は微調整します。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
+ model User {
+ id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
+ user String @unique
+ password String
+ todos Todo[]
+
+ @@map("Users")
+ }
+
+ model Todo {
+ id Int @id @default(autoincrement())
+ userId String @db.Uuid
+ タスク String
+ is完了 Boolean @default(false)
+ createdAt DateTime @default(now()) @db.Timestamp(6)
+ User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
+
+ @@map("Todos")
+ }
セキュリティー上、ユーザーIDを連番にしてしまうと推測が容易なのでuuidを取得するようにしてます。
✅ テーブルスキーマを変更した後のPrismaの反映
開発中にやむを得ずテーブル定義を変更することは「あるある」だと思います
一般的には schema.prisma を変更したら、下記のコマンドを実行と説明されてます
npx prisma generate
しかし、今回アプリ作成中に何度か実行したところ、カラム名の変更や型の変更(Int→String)を行った際は、「完全削除してからの再生成(Clean Build)」 が最も確実で安全でした
中途半端な上書き生成は、古い型定義の「亡霊」を残す原因になるので避けてます
手順 1: 生成物とキャッシュの物理削除
# 1. Prismaの実体(エンジンとクライアント)を削除
rm -rf node_modules/.prisma
rm -rf node_modules/@prisma/client
# 2. Next.jsのキャッシュも削除(これが古い型を握りしめていることが多い)
rm -rf .next
手順 2: パッケージの依存関係を整える(念のため)
npm install
手順 3: Prisma Generate の実行
npx prisma generate
手順 4: VS Code や Cursorの再起動
エディタのキャッシュを確実にリフレッシュさせます
7. データアクセス層(リポジトリ)の作成
src/lib/todo.ts を作成します。
SQLやLINQに慣れている僕にも、このメソッドベース・クエリは馴染みました
A) ModelはPrismaの自動作成結果から読み込む
import { Todo } from "@prisma/client";
B) ログイン者のTodoを全件select
export const getMyTodos = async (userId: string): Promise<Todo[]> => {
const todos = await prisma.todo.findMany({
where: {
userId: userId,
},
orderBy: [{ is完了: "asc" }, { createdAt: "asc" }],
});
return todos;
};
getMyTodos = (): Todo[]
と書きたいところですが、prismaからのデータ取得が非同期で行われるので
getMyTodos = async (): Promise<Todo[]>
と書きます。
C) Insert(Todoを追加)
export const addTodo = async (userId: string, タスク: string) => {
await prisma.todo.create({
data: {
userId,
タスク,
},
});
};
D) Update(完了/未完了を切り替えて更新)
export const toggleTodo = async (userId: string, id: number) => {
const todo = await prisma.todo.findUnique({
where: {
id,
userId,
},
});
if (!todo) return;
await prisma.todo.update({
where: {
id,
},
data: {
is完了: !todo.is完了,
},
});
};
E) Delete(物理削除)
export const deleteTodo = async (userId: string, id: number) => {
await prisma.todo.delete({
where: {
id,
userId,
},
});
};
8.コントローラーの作成
Todoが 追加、修正、削除 されたら表示されている一覧を再描画するのは必須だと思います。
ただそれはModel層の責務でも、View層の責務でもありません。
Model層のInsertを呼び出すときは、必ずView層へ更新を通知するコントローラーを用意します。
Server Action のコントローラー層で NextAuthから安全にセッションを取り出すことで クライアントのCookie書き換え攻撃から防衛してます
セキュリティの観点(なりすまし防止・IDOR対策)から DBスペシャリストとして、クライアントからのIDは信用しません
src/app/(protected)/actions.ts を作成します。
"use server";
import { addTodo, deleteTodo, toggleTodo } from "@/lib/todo";
import { revalidatePath } from "next/cache";
import { signOut } from "@/lib/auth";
import { auth } from "@/lib/auth";
export const getUserId = async () => {
// セッション切れ、または未ログインなら拒否
const session = await auth();
if (!session?.user?.id) {
throw new Error("認証切れ、またはログインしていません");
}
const userId = session.user.id;
return userId;
};
export const addTodoAction = async (title: string) => {
// DB操作
const userId = await getUserId();
await addTodo(userId, title);
// UI操作(ルートページのキャッシュ todosを廃棄して再作成)
revalidatePath("/");
};
export const toggleTodoAction = async (id: number) => {
// DB操作
const userId = await getUserId();
await toggleTodo(userId, id);
// UI操作(ルートページのキャッシュ todosを廃棄して再作成)
revalidatePath("/");
};
export const deleteTodoAction = async (id: number) => {
// DB操作
const userId = await getUserId();
await deleteTodo(userId, id);
// UI操作(ルートページのキャッシュ todosを廃棄して再作成)
revalidatePath("/");
};
export const logoutAction = async () => {
await signOut({ redirectTo: "/login" });
};
9.VIEWの作成
✅ VIEW層の正規化について考える
VIEWが肥大化しないように、VIEW層の分割について考えます
DBのテーブル設計と同じく、関心事を分離することで見通しを良くします
TodoアプリのVIEWを分割すると
① page.tsx ルートページ
② TodoApp.tsx 全体を統括するコンポーネント
③ TodoList.tsx 個々のTodo(④)をリスト表示
④ TodoItem.tsx 最小のコンポーネント 1件のTodo を表示
10.VIEW層① page.tsx ルートページ
export default async function Home() {
// ログイン者のUserId取得
const session = await auth();
if (!session?.user?.id) {
redirect("/login");
}
const userId = session.user.id;
// ログイン者のTodoを表示
const todos = await getMyTodos(userId);
return (
<main className="min-h-screen flex items-center justify-center p-4 bg-background">
<TodoApp todos={todos} />
</main>
);
}
ログイン者のTodoの一覧 todosを取得し、コンポーネント TodoApp を todos へ依存させます。
この依存関係があるから、コントローラーの revalidatePath("/"); で ルートページがリフレッシュされます
11.VIEW層② TodoAppコンポーネント 全体を統括
✅ 必要なものをPropsで受け取る
ルートページからログイン者の Todo の一覧を受け取ります。
type PropsType = {
todos: Todo[];
};
✅ 変化する状態(useState)について考える
- ユーザーが入力する新しいタスク
- 「完了したタスクも表示」するか否か
const [newTodo, setNewTodo] = useState("");
const [showCompleted, setShowCompleted] = useState(false);
✅ アクション(ユーザーの操作)について考える
- 「+」ボタンでタスクを追加
- タスク入力後にエンターキー
- タスクの完了をチェック/チェックオフ
- タスクのゴミ箱アイコンをクリック
- 「完了したタスクも表示」をチェック/チェックオフ
カプセル化の観点から、個人的には「タスクの完了チェック」など、個々のタスクへのアクションは TodoItemコンポーネント など そのアクションを必要とするコンポーネントへ書きたいところです。
ですが、Reactの設計パターン(特に Container / Presentational パターン)の観点では親のコンポーネントで一元管理するのが正解です
✅ Container (Smart) Component = TodoApp
役割: データの取得、更新、ルーター操作、エラーハンドリングなどの「ロジック」を担当。
特徴: アクションの動作を定義して Presentational へ渡す
✅ Presentational (Dumb) Component = TodoItem / TodoList
役割: データを受け取って表示し、ボタンが押されたら親に「押されたよ」と報告するだけ。
特徴: アクションの動作は Container から受け取り中身を信頼して実行するだけ
// 「+」ボタンでタスクを追加
const handleAdd = async () => {
const title = newTodo.trim();
if (title === "") return;
try {
await addTodoAction(title);
setNewTodo("");
} catch (e) {
router.replace("/login");
router.refresh();
}
};
// タスク入力後にエンターキー
const handleKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") await handleAdd();
};
// タスクの完了をチェック/チェックオフ
const handleToggle = async (id: number) => {
try {
await toggleTodoAction(id);
} catch (e) {
router.replace("/login");
router.refresh();
}
};
// タスクのゴミ箱アイコンをクリック
const handleDelete = async (id: number) => {
try {
await deleteTodoAction(id);
} catch (e) {
router.replace("/login");
router.refresh();
}
};
// 「完了したタスクも表示」をチェック/チェックオフ
const filteredTodos = showCompleted
? todos
: todos.filter((todo) => !todo.is完了);
✅ HTMLの定義
{/* 入力エリア */}
<div className="flex gap-2">
<Input
type="text"
placeholder="新しいタスクを入力..."
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1"
/>
<Button onClick={handleAdd} size="icon">
<Plus className="h-4 w-4" />
<span className="sr-only">タスクを追加</span>
</Button>
</div>
{/* フィルター操作 */}
<div className="flex items-center gap-2">
<Checkbox
id="showCompleted"
checked={showCompleted}
onCheckedChange={(checked) => setShowCompleted(checked === true)}
/>
<label
htmlFor="showCompleted"
className="text-sm text-muted-foreground cursor-pointer"
>
完了したタスクも表示
</label>
</div>
{/* ★ リスト表示 */}
<TodoList
todos={filteredTodos}
onToggle={handleToggle}
onDelete={handleDelete}
/>
12.VIEW層③ TodoListコンポーネント リスト表示
✅ 必要なこと
- 一覧表示用のTodoの配列が必要
- 一覧内の完了/未完了のチェックボックスの値が変わった時に実行する処理が必要
- 一覧内の削除用のゴミ箱アイコンがクリックされた時に実行する処理が必要
✅ 必要なものをPropsで受け取る
type Props = {
// 表示用のTodoの配列情報
todos: Todo[];
// 完了チェックボックスをチェックされたときの動作内容
onToggle: (id: number) => void;
// 削除アイコンをクリックされたときの動作内容
onDelete: (id: number) => void;
};
Todoの配列が0件なら「タスクがありません」
if (todos.length === 0) {
return (
<p className="text-center text-muted-foreground py-4">
タスクがありません
</p>
);
}
Todoの配列をmap展開して表示
<div className="space-y-2">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</div>
13.VIEW④ 最小のコンポーネント(1件のTodo を表示)
✅ 必要なこと
- 表示用のTodo(1件分)の情報が必要
- 完了/未完了の用のチェックボックスがある
→ チェックボックスの値が変わった時に実行する処理が必要 - 削除用のゴミ箱アイコン
→ クリックされた時に実行する処理が必要
✅ 必要なものをPropsで受け取る
type Props = {
// 表示用のTodoの情報
todo: Todo;
// 完了チェックボックスをチェックされたときの動作内容
onToggle: (id: number) => void;
// 削除アイコンをクリックされたときの動作内容
onDelete: (id: number) => void;
};
完了チェックボックへTodoの状態からチェックの有無を表示 + チェックされたときの動作を指定
<Checkbox
checked={todo.is完了}
onCheckedChange={() => onToggle(todo.id)}
aria-label={`${todo.タイトル}を${
todo.is完了 ? "未完了" : "完了"
}にする`}
/>
ゴミ箱をクリックして削除も同様
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(todo.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">削除</span>
</Button>
14.まとめ
プロジェクト全体としてファイルの数は増えましたが、それ以上に 「どこに何が書かれているか迷わない」 という大きなメリットが得られたと思います。
AIにコードを書かせると、どうしても1つのファイルにロジックが集中しがちですが、意図してMVCパターンに落とし込むことで以下の効果を実感できました。
✅ 認知的負荷の低減
「DB操作なら lib/todo.ts」「画面の挙動なら TodoApp.tsx」と脳内のスイッチを切り替えやすく、デバッグが容易になりました。
✅ 堅牢な型安全性
Prisma と TypeScript のおかげで、DBスキーマの変更が即座にコードの型エラーとして検知できるため、リファクタリングの手間と心理的ハードルが激減しました。
✅ バックエンド経験が活きる
Next.js (App Router) は、実はサーバーサイドエンジニアが慣れ親しんだ「MVC」や「ステートレス」な考え方と非常に相性が良いと感じました。
最初は僕自身の学習用と割り切って始めた本アプリの開発ですが、初期の簡易的な実装だと少しの変更でも「何がどこに書かれているか」「修正がどこまで影響するか」を見極めるのが大変でした。
「あるべきものがあるべき姿にないと、あとで必ず不都合がおきる」
この基本原則に立ち返り整理した結果をこの記事にまとめました。
これから Next.js に挑戦される同業(DB屋さん、C#屋さん)の方々の、なにかのお役にたてれば幸いです。
15.おまけ(AWS Amplifyデプロイで躓いた話)
1) Prismaの「バージョン」の罠
現象: 公式ドキュメント通りに npm install prisma すると、開発中の v7 (Early Access) が入り、Amplifyへデプロイ時に設定ファイル地獄(prisma.config.tsなど)に落ちる。
教訓: ドキュメントを鵜呑みにせず、安定版 (v6系) を固定して入れること。
解決コマンド:
npm install prisma@6.2.1 @prisma/client@6.2.1 --save-exact
※ prisma.config.ts は削除し、schema.prisma は昔ながらの provider = "prisma-client-js" でOK。
2) Next.js App Routerの「ビルド時接続」の罠
現象: ローカルでは動くのに、Amplifyのビルドログで Can't reach database と出て落ちる。
原因: Next.jsがビルド時にページを静的生成(SSG)しようとしてDBに接続しに行き、失敗する。
解決策: src/app/page.tsx に一行追加して、動的レンダリングを強制する。
export const dynamic = 'force-dynamic';
3) Supabase × AWSの「接続拒否」の罠
現象: デプロイ成功後、ブラウザでアクセスするとエラーになる(Digestエラー)。
原因: AWS Amplify(サーバーレス)のIPアドレスは固定できないため、Supabaseのファイアウォールに弾かれる。また、IPv6等の兼ね合いでポート5432が繋がりにくい。
解決策:
- Supabaseの「Connect」→「Transaction Mode」を使う
- ポート 6543 の接続文字列を使う
- ?pgbouncer=true を末尾に付ける
- Amplifyの環境変数 DATABASE_URL に設定する






