Claude CodeでNext.jsフルスタック開発:App Router実務ガイド
Claude CodeでNext.js App Routerの境界設計、Server Actions、API実装、認証、レビュー手順まで学ぶ実務ガイド。
Claude Codeに「Next.jsでフルスタックアプリを作って」と頼むだけでも、画面、API、フォーム処理はかなり速く形になります。けれど、App Routerの境界を曖昧にしたまま進めると、サーバー専用の秘密情報がブラウザ側に混ざったり、Server ComponentとClient Componentが入り乱れたりして、あとから直すほうが高くつきます。
この記事では、2026年6月1日時点のNext.js App Routerを前提に、Claude Codeへ安全に任せやすい設計の型をまとめます。題材は「ログイン済みユーザーがタスクを作る小さな管理画面」です。Server Components、Client Components、Route Handlers、Server Actions、バリデーション、環境変数、認証境界、最後のレビュー依頼まで、コピペして動かせる粒度で整理します。
まず公式情報として、App Router全体はNext.js App Router docs、Server ComponentとClient Componentの違いはServer and Client Components、HTTP APIはRoute Handlers、フォーム更新はMutating Dataを確認してください。Claude Code側の作業の進め方はClaude Code common workflowsが基準になります。
最初に境界を決める
初心者が最初につまずくのは、Next.jsの機能名よりも「どのコードがどこで動くか」です。Server Componentはサーバーで描画する部品、Client Componentはブラウザで動く部品です。Route Handlerは外部からHTTPで呼ばれる窓口、Server Actionはフォームやボタンから呼ぶサーバー処理です。BFFはBackend for Frontendの略で、画面専用に薄く作る裏側APIという意味です。
| 領域 | 使う場面 | 置いてよいもの | Claude Codeへの注意 |
|---|---|---|---|
| Server Component | 初期表示、DB読み取り、SEOが必要な画面 | DBアクセス、認証確認、非公開API呼び出し | 既定はここに置くよう指示する |
| Client Component | 入力フォーム、モーダル、タブ、楽観的UI | useState、useActionState、クリック処理 | 秘密情報やDBクライアントを入れない |
| Server Action | フォーム送信、作成、更新、削除 | バリデーション、権限確認、再検証 | 公開APIの代わりにしない |
| Route Handler | 外部連携、Webhook、モバイルアプリ向けAPI | JSONレスポンス、ステータスコード、署名検証 | 入力検証と認証を必ず入れる |
この表をClaude Codeに先に渡すと、生成結果がかなり安定します。特に「Client Componentにprocess.env、DB、認証秘密鍵を入れない」と明記してください。Next.jsではNEXT_PUBLIC_が付いた環境変数だけがブラウザ向けです。秘密鍵、DB URL、APIトークンはサーバー専用ファイルに閉じ込めます。
flowchart TD
Browser[ブラウザのフォーム] --> Client[Client Component]
Client --> Action[Server Action]
External[外部サービス] --> Route[Route Handler]
Page[Server Component] --> Auth[認証境界]
Action --> Auth
Route --> Auth
Auth --> Data[DBまたはサーバー専用ロジック]
Data --> Page
実務で使うプロジェクト構成
Claude Codeには、先にファイル配置を指定します。App Routerはファイル名がルーティングそのものになるため、構成の指定が曖昧だとcomponentsやappの中に処理が散らばります。
src/
app/
dashboard/
tasks/
page.tsx
new/
page.tsx
actions.ts
api/
tasks/
route.ts
components/
task-create-form.tsx
lib/
auth.ts
env.ts
tasks.ts
この構成では、page.tsxが初期表示、task-create-form.tsxが入力UI、actions.tsがフォーム更新、route.tsが外部向けAPI、libがサーバー専用ロジックです。最初は小さく見えますが、SaaSの設定画面、社内申請ツール、ブログCMS、顧客管理ダッシュボードでも同じ型を使えます。
サーバー専用ロジックを隔離する
次の例はデモ用のインメモリ保存です。本番ではPrisma、Drizzle、Supabaseなどに置き換えてください。重要なのは、server-onlyを入れてClient Componentから誤って読み込めないようにすることです。
// src/lib/tasks.ts
import "server-only";
export type TaskPriority = "low" | "normal" | "high";
export type Task = {
id: string;
ownerId: string;
title: string;
priority: TaskPriority;
dueDate: string | null;
createdAt: string;
};
const tasks: Task[] = [];
export async function listTasks(options: {
ownerId: string;
priority?: TaskPriority;
}) {
return tasks
.filter((task) => task.ownerId === options.ownerId)
.filter((task) => !options.priority || task.priority === options.priority)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export async function createTask(input: {
ownerId: string;
title: string;
priority: TaskPriority;
dueDate?: string | null;
}) {
const task: Task = {
id: crypto.randomUUID(),
ownerId: input.ownerId,
title: input.title,
priority: input.priority,
dueDate: input.dueDate ?? null,
createdAt: new Date().toISOString(),
};
tasks.push(task);
return task;
}
認証境界も同じくサーバー専用にします。以下は動作確認用の簡易実装です。ブラウザのCookieにdemo_user_idがあればログイン済みとみなします。本番ではAuth.js、Clerk、自社認証などに差し替えてください。
// src/lib/auth.ts
import "server-only";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export type CurrentUser = {
id: string;
name: string;
role: "member" | "admin";
};
export async function getCurrentUser(): Promise<CurrentUser | null> {
const cookieStore = await cookies();
const userId = cookieStore.get("demo_user_id")?.value;
if (!userId) {
return null;
}
return {
id: userId,
name: "Demo User",
role: "member",
};
}
export async function requireUser() {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
return user;
}
export async function requireApiUser() {
return getCurrentUser();
}
環境変数はprocess.envを直接ばらまかず、1か所で検証します。DATABASE_URLやAPP_SECRETはClient Componentから絶対にimportしないでください。
// src/lib/env.ts
import "server-only";
import { z } from "zod";
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
APP_SECRET: z.string().min(32),
});
export const env = EnvSchema.parse(process.env);
Server Componentで初期表示を作る
タスク一覧はServer Componentにします。DB読み取り、認証、SEOに関わる初期表示は、まずサーバー側で済ませるほうが単純です。Claude Codeには「このファイルにuse clientを付けない」と明示すると、不要なClient Component化を防げます。
// src/app/dashboard/tasks/page.tsx
import Link from "next/link";
import { requireUser } from "@/lib/auth";
import { listTasks } from "@/lib/tasks";
export default async function TasksPage() {
const user = await requireUser();
const tasks = await listTasks({ ownerId: user.id });
return (
<main className="mx-auto max-w-3xl space-y-6 p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">タスク管理</h1>
<p className="text-sm text-gray-600">{user.name}さんの作業一覧</p>
</div>
<Link className="rounded bg-black px-4 py-2 text-white" href="/dashboard/tasks/new">
新規作成
</Link>
</div>
<ul className="divide-y rounded border">
{tasks.map((task) => (
<li className="flex items-center justify-between p-4" key={task.id}>
<div>
<p className="font-medium">{task.title}</p>
<p className="text-sm text-gray-500">
優先度: {task.priority} / 期限: {task.dueDate ?? "未設定"}
</p>
</div>
</li>
))}
</ul>
</main>
);
}
Server Actionで更新処理を書く
フォーム更新はServer Actionに集めます。ポイントは、認証、入力検証、保存、再検証を同じ関数内で順番に行うことです。Zodは入力データの形をチェックするライブラリで、Claude Codeに「ここで必ずsafeParseする」と指定しておくと、危ないrequest.json()直通保存を減らせます。
// src/app/dashboard/tasks/new/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireUser } from "@/lib/auth";
import { createTask } from "@/lib/tasks";
const CreateTaskSchema = z.object({
title: z.string().trim().min(1, "タイトルは必須です").max(80),
priority: z.enum(["low", "normal", "high"]),
dueDate: z
.string()
.trim()
.optional()
.transform((value) => (value ? value : null)),
});
export type TaskFormState = {
ok: boolean;
message?: string;
fieldErrors?: Record<string, string[]>;
};
export async function createTaskAction(
previousState: TaskFormState,
formData: FormData
): Promise<TaskFormState> {
const user = await requireUser();
const parsed = CreateTaskSchema.safeParse({
title: formData.get("title"),
priority: formData.get("priority"),
dueDate: formData.get("dueDate"),
});
if (!parsed.success) {
return {
ok: false,
fieldErrors: parsed.error.flatten().fieldErrors,
message: "入力内容を確認してください。",
};
}
await createTask({
ownerId: user.id,
...parsed.data,
});
revalidatePath("/dashboard/tasks");
return {
ok: true,
message: "タスクを作成しました。",
};
}
Client Component側は、ブラウザで必要な状態だけを持たせます。ここにDB、秘密鍵、サーバー専用のenvをimportしてはいけません。
// src/components/task-create-form.tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import {
createTaskAction,
type TaskFormState,
} from "@/app/dashboard/tasks/new/actions";
const initialState: TaskFormState = {
ok: false,
};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
className="rounded bg-black px-4 py-2 text-white disabled:opacity-50"
disabled={pending}
type="submit"
>
{pending ? "作成中..." : "作成する"}
</button>
);
}
export function TaskCreateForm() {
const [state, formAction] = useActionState(createTaskAction, initialState);
return (
<form action={formAction} className="space-y-4 rounded border p-4">
<div>
<label className="block text-sm font-medium" htmlFor="title">
タイトル
</label>
<input
className="mt-1 w-full rounded border px-3 py-2"
id="title"
name="title"
type="text"
/>
{state.fieldErrors?.title?.map((error) => (
<p className="mt-1 text-sm text-red-600" key={error}>
{error}
</p>
))}
</div>
<div>
<label className="block text-sm font-medium" htmlFor="priority">
優先度
</label>
<select className="mt-1 w-full rounded border px-3 py-2" id="priority" name="priority">
<option value="normal">通常</option>
<option value="high">高</option>
<option value="low">低</option>
</select>
</div>
<div>
<label className="block text-sm font-medium" htmlFor="dueDate">
期限
</label>
<input className="mt-1 w-full rounded border px-3 py-2" id="dueDate" name="dueDate" type="date" />
</div>
{state.message ? <p className="text-sm text-gray-700">{state.message}</p> : null}
<SubmitButton />
</form>
);
}
// src/app/dashboard/tasks/new/page.tsx
import { TaskCreateForm } from "@/components/task-create-form";
export default function NewTaskPage() {
return (
<main className="mx-auto max-w-xl p-6">
<h1 className="mb-4 text-2xl font-bold">タスクを作成</h1>
<TaskCreateForm />
</main>
);
}
Route Handlerで外部向けAPIを作る
外部サービス、モバイルアプリ、Webhookから呼ぶならRoute Handlerを使います。Server Actionは画面内部の更新に向いていますが、外部に公開するAPIとして扱うと、ステータスコード、認証失敗、JSON形式のエラーが曖昧になります。
// src/app/api/tasks/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireApiUser } from "@/lib/auth";
import { createTask, listTasks } from "@/lib/tasks";
export const runtime = "nodejs";
const PrioritySchema = z.enum(["low", "normal", "high"]);
const CreateTaskApiSchema = z.object({
title: z.string().trim().min(1).max(80),
priority: PrioritySchema.default("normal"),
dueDate: z.string().date().nullable().optional(),
});
export async function GET(request: NextRequest) {
const user = await requireApiUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const priority = request.nextUrl.searchParams.get("priority");
const parsedPriority = priority ? PrioritySchema.safeParse(priority) : null;
if (parsedPriority && !parsedPriority.success) {
return NextResponse.json({ error: "Invalid priority" }, { status: 400 });
}
const tasks = await listTasks({
ownerId: user.id,
priority: parsedPriority?.data,
});
return NextResponse.json({ data: tasks });
}
export async function POST(request: NextRequest) {
const user = await requireApiUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => null);
const parsed = CreateTaskApiSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request body", details: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const task = await createTask({
ownerId: user.id,
...parsed.data,
});
return NextResponse.json({ data: task }, { status: 201 });
}
3つ以上の実務ユースケース
1つ目は社内申請ダッシュボードです。申請一覧はServer Component、申請作成はServer Action、外部チャット通知はRoute Handlerに分けると、画面と外部連携の責務が混ざりません。
2つ目はSaaSの設定画面です。請求設定、チーム招待、APIキー発行のような操作は、Client Componentに最小限の入力UIだけを置き、実際の変更はServer Actionで権限確認してから行います。
3つ目はブログCMSや商品管理です。初期一覧はServer Componentで高速に出し、画像アップロードや公開WebhookはRoute Handlerに寄せます。DBマイグレーションを含めるならClaude CodeでDBマイグレーションを自動化する方法も合わせて読むと設計がつながります。
4つ目はBFF構成です。フロントエンドから直接複数の外部APIを呼ばず、Next.js側のRoute Handlerに集約します。Next.js公式のBackend for Frontend guideの考え方に近く、秘密鍵をブラウザに出さずに済みます。
よくある失敗と落とし穴
一番危ないのは、Claude Codeが便利だからといって境界の確認を省くことです。たとえばClient ComponentにDATABASE_URLを読むヘルパーをimportさせる、初期表示なのに全部useEffectでAPI取得させる、Server Actionを外部APIのように使う、Route Handlerでrequest.json()を検証せずDBへ流す、といった失敗は実務でも起きます。
認証もUIだけで守ってはいけません。「ボタンを非表示にしたから削除できない」は防御ではありません。Server ActionとRoute Handlerの両方でrequireUserやrequireApiUserを通し、さらに所有者IDやロールを確認します。認証の全体像はClaude Codeで認証機能を実装する方法が参考になります。
もう1つの落とし穴は、Claude Codeに「いい感じに直して」と頼み続けることです。App Routerでは小さな修正でも、キャッシュ、再検証、フォーム状態、認証境界に影響します。変更範囲、禁止事項、検証コマンドを毎回渡したほうが、結果が安定します。
Claude Codeに渡すレビュープロンプト
実装後は、次のようにレビューだけを依頼します。いきなり修正させず、まず指摘を出させるのがコツです。
You are reviewing a Next.js App Router full-stack change.
Scope:
- src/app/dashboard/tasks
- src/app/api/tasks/route.ts
- src/components/task-create-form.tsx
- src/lib/auth.ts
- src/lib/tasks.ts
- src/lib/env.ts
Check:
1. No secrets, DB clients, or server-only modules are imported by Client Components.
2. Server Components are not converted to Client Components without a real interaction need.
3. Server Actions validate input, check auth, mutate data, and revalidate the affected path.
4. Route Handlers return correct HTTP status codes and validate JSON bodies.
5. Auth is enforced on the server, not only hidden in the UI.
6. Tests or manual verification steps are listed for each risk.
Do not edit files yet. Return findings by severity with file paths and concrete fixes.
指摘が出たら、1件ずつ小さく直します。検証は最低でもnpm run lint、npm run typecheck、関連するユニットテスト、フォームの手動送信、未ログイン時のRoute Handlerの401確認まで行います。テスト設計を広げたい場合はClaude Codeでテスト戦略を立てる方法も参照してください。
ClaudeCodeLabの実務導線
個人で試す段階なら、この記事の構成とプロンプトをそのまま使えば十分です。チームで標準化するなら、Claude Codeに渡すレビュー観点、App Routerの境界ルール、認証と環境変数の禁止事項をテンプレート化したほうが事故が減ります。
ClaudeCodeLabでは、実務向けのClaude Code教材・テンプレート集と、チーム導入を整理するClaude Code導入相談・研修を用意しています。Next.jsの社内管理画面、CMS、SaaS設定画面をClaude Codeで安全に作る流れを、自社のリポジトリ規約に合わせて整えたい場合に使ってください。
まとめ
Claude CodeとNext.jsを組み合わせると、フルスタック開発は速くなります。ただし、速さを出すほど「どこがサーバーで、どこがブラウザか」を先に固定する必要があります。Server Componentは初期表示、Client Componentは入力UI、Server Actionは画面内の更新、Route Handlerは外部API。この4分割を守るだけで、Claude Codeの生成結果はかなりレビューしやすくなります。
この記事で紹介した内容を実際に試した結果、最初に境界表とファイル構成をClaude Codeへ渡したケースでは、修正依頼の多くが文言やUI調整に寄り、秘密情報の混入や不要なuse clientの追加は明らかに減りました。逆に、最初の指示を曖昧にした場合は、フォーム処理とAPI処理が混ざり、レビューで戻す作業が増えました。Next.jsのフルスタック開発では、実装速度より先に境界設計を言語化することが一番効きます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。