Use Cases (更新: 2026/6/7)

Next.js App Routerで境界設計:Server Actionsで事故らないフルスタック開発

Next.js App RouterのServer/Client境界、Server Actions、Route Handler、認証を設計する手順。秘密情報の漏れと不要なuse client追加を防ぐ型をコピペコード付きで解説。

Next.js App Routerで境界設計:Server Actionsで事故らないフルスタック開発

「Next.jsでタスク管理画面、サクッと作って」

そうClaude Codeに頼んだら、5分で動く画面が出てきました。フォームも一覧も完璧。テンションが上がって、そのままレビューもそこそこにマージしたんです。

その夜、ふと気づきました。一覧を出すコンポーネントの先頭に "use client" が付いていて、そこから DATABASE_URL を読むヘルパーがimportされていた。つまり、本来サーバーだけにあるはずの接続文字列が、ブラウザに送るバンドルの中に紛れ込む経路ができていたわけです。

血の気が引きました。

賢いAIが、なぜこんな初歩的な事故を起こすのか。理由はシンプルで、App Routerは「どのコードがどこで動くか」を曖昧にしたまま書けるからです。サーバーとブラウザの境界線を引いてやらないと、人間でもAIでも同じ穴に落ちます。今日はその境界を先に決めて、Claude Codeに安全に任せる型を、コピペで動く粒度でまとめます。

題材は「ログイン済みユーザーがタスクを作る、小さな管理画面」です。

この記事の要点

  • App RouterはServer Component(サーバー描画)/ Client Component(ブラウザ動作)/ Server Action(画面内の更新)/ Route Handler(外部向けAPI)の4つに役割を分けるだけで、生成結果が一気にレビューしやすくなる。
  • 一番危ない事故は秘密情報がブラウザに漏れることserver-only と「NEXT_PUBLIC_ の付いた環境変数だけがブラウザ向け」を徹底すれば防げる。
  • 認証はUIで隠すだけでは守れない。Server ActionもRoute Handlerも、関数の中で毎回ログインと権限を確認する(Server Functionは直POSTでも呼べるため)。
  • Claude Codeには境界表とファイル構成を先に渡す。曖昧な指示だとフォーム処理とAPI処理が混ざり、戻し作業が増える。
  • レンダリング方式(SSR/SSG/ISR)の選び方は別記事で。本記事は「サーバーとブラウザの責務分割」に絞る。

この記事は2026年6月時点のNext.js App Router(16系)を前提にしています。公式の一次情報は、全体像がNext.js App Router docs、境界の話がServer and Client Components、データ更新がMutating Dataです。レンダリング方式そのものをどう選ぶかはCSR/SSR/SSG/ISRの違いと選び方で別に扱っているので、本記事は責務分割に集中します。

まず引くべきは、機能の線ではなく「場所」の線

App Routerを触り始めた人がつまずくのは、Server Action とか Route Handler という名前そのものではありません。「このコード、サーバーで動くの?ブラウザで動くの?」が頭の中で分かれていないことです。

ここさえ整理すれば、あとは自然と置き場所が決まります。僕がClaude Codeに毎回貼っているのが、次の表です。

領域使う場面置いてよいものClaude Codeへの注意
Server Component初期表示、DB読み取り、SEOが必要な画面DBアクセス、認証確認、非公開API呼び出し既定はここに置くよう指示する
Client Component入力フォーム、モーダル、タブ、楽観的UIuseStateuseActionState、クリック処理秘密情報やDBクライアントを入れない
Server Actionフォーム送信、作成、更新、削除バリデーション、権限確認、再検証公開APIの代わりにしない
Route Handler外部連携、Webhook、モバイル向けAPIJSONレスポンス、ステータスコード、署名検証入力検証と認証を必ず入れる

用語をざっくり言い換えておきます。Server Componentはサーバーで描いてから送る部品、Client Componentはブラウザで動く部品。Server Actionはフォームやボタンから呼ぶサーバー処理で、Route Handlerは外部からHTTPで叩かれる窓口です。BFFという言葉も出てきますが、これは「画面専用に薄く作った裏側のAPI」くらいの意味だと思ってください。

この表を先に渡すと、Claude Codeの生成がかなり安定します。特に「Client Componentに process.env・DB・認証秘密鍵を入れない」の一文は外さないでください。冒頭の事故は、まさにこれを言い忘れた結果でした。

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

ファイル配置を先に指定する

App Routerはファイル名がそのままURLになります。だから配置を曖昧にしておくと、処理が componentsapp の間に散らばって、後から「あれ、この更新どこに書いたっけ」が始まります。Claude Codeにも、まずこの形を渡します。

src/
  app/
    dashboard/
      tasks/
        page.tsx          # 一覧(Server Component)
        new/
          page.tsx        # 作成画面
          actions.ts      # Server Action
    api/
      tasks/
        route.ts          # 外部向けAPI(Route Handler)
  components/
    task-create-form.tsx  # 入力UI(Client Component)
  lib/
    auth.ts               # 認証(サーバー専用)
    env.ts                # 環境変数の検証(サーバー専用)
    tasks.ts              # データ操作(サーバー専用)

小さく見えますが、この型はSaaSの設定画面でも、社内申請ツールでも、ブログCMSでも、顧客管理ダッシュボードでもそのまま使い回せます。要は「画面」「画面内の更新」「外部向けAPI」「サーバー専用ロジック」の4つに分けているだけです。

サーバー専用ロジックを物理的に隔離する

次がデータ操作の本体です。デモなのでインメモリ(メモリ上の配列)に保存していますが、本番ではPrisma・Drizzle・Supabaseなどに差し替えてください。差し替えても境界の考え方は変わりません。

ここで一番大事なのは、1行目の import "server-only"; です。これを付けておくと、もしClient Componentからうっかりimportしたときにビルドが落ちて教えてくれます。冒頭の僕の事故も、これが入っていれば公開前に止まっていました。

// 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;
}

認証も同じく server-only 側に閉じ込めます。下は動作確認用の簡易版で、ブラウザのCookieに demo_user_id があればログイン済みとみなすだけのものです。本番ではAuth.js・Clerk・自社認証などに差し替えてください。認証そのものの設計(セッションかJWTか、パスワード保存はどうするか)はWeb認証はセッション・bcrypt・Cookieで固めるに分けて書きました。

// 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_URLAPP_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読み取り・認証・初期表示は、まずサーバーで済ませたほうが単純で速いからです。Claude Codeには「このファイルに use client を付けない」と一言添えると、不要なクライアント化を防げます。

// 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>
  );
}

async function のまま await listTasks(...) を呼んでいる点に注目してください。Server Componentだからこそ、こうやってDBに近い場所でデータを取って、そのまま描けます。useEffect で取りに行く必要はありません。

Server Actionで更新処理を1か所に集める

ここがフルスタックの肝です。フォームからの更新はServer Actionにまとめます。順番が大事で、認証 → 入力検証 → 保存 → 再検証 を同じ関数の中で上から順にやります。

Zodは「入力データの形が想定どおりか」をチェックするライブラリです。Claude Codeに「ここで必ず safeParse する」と指定しておくと、検証なしで保存に流す危ない実装が減ります。

公式ドキュメントが強めに警告しているとおり、Server Action(Server Function)はアプリのUIを経由しなくても、直接POSTで呼べてしまいます。だから「ボタンを押せる人だけが呼ぶ」という前提は通用しません。関数の入り口で requireUser() を必ず通すのは、そのためです。

// 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> {
  // 1. まず認証。UIで隠すだけでは守れない
  const user = await requireUser();

  // 2. 入力検証。request の中身を信用しない
  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: "入力内容を確認してください。",
    };
  }

  // 3. 保存
  await createTask({
    ownerId: user.id,
    ...parsed.data,
  });

  // 4. 一覧のキャッシュを更新して、最新を表示させる
  revalidatePath("/dashboard/tasks");

  return {
    ok: true,
    message: "タスクを作成しました。",
  };
}

Client Component側は、ブラウザで本当に必要な状態だけを持たせます。ここにDB・秘密鍵・サーバー専用の env をimportしてはいけません。フォームの送信中表示には useActionStateuseFormStatus を使います。

// 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>
  );
}

作成画面のページ本体は、このフォームを置くだけのServer Componentです。

// 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>
  );
}

ここまでで、フォーム送信からDB保存、一覧の自動更新までが一周します。<form action={formAction}> という1行で、ブラウザのフォームとサーバーの処理がつながっているのがポイントです。

外部向けAPIはRoute Handlerに分ける

「じゃあ更新は全部Server Actionでいいのか」というと、そうではありません。モバイルアプリ・他社サービス・Webhookから叩かれるAPIは、Route Handlerにします。Server Actionは画面内部の更新に最適化されていて、外部公開APIとして使うと、ステータスコードやJSON形式のエラーが曖昧になりがちだからです。

Route Handlerでは、入力検証と認証を自分で明示的に書きます。下のコードは、未ログインなら401、変な優先度なら400、成功時は201を返します。

// 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 });
}

request.json().catch(() => null) で受けてから safeParse に通している点に注意してください。壊れたJSONや空ボディが来ても落ちずに400で返せます。REST APIの組み立て方そのもの(ルーティング・エラー統一・ページネーション)を深掘りしたい人はREST APIをNodeで実装するも合わせてどうぞ。

実務で効く4つの分け方

この4分割が活きる場面を、具体的に挙げます。

1つ目は社内申請ダッシュボード。申請一覧はServer Component、申請の作成はServer Action、Slackなどへの通知はRoute Handler。こう分けると、画面と外部連携の責務が混ざりません。

2つ目はSaaSの設定画面。請求設定・チーム招待・APIキー発行のような操作は、Client Componentに入力UIだけを置いて、実際の変更はServer Actionで権限確認してから実行します。「ボタンを押せた=権限がある」にしないのがコツです。

3つ目はブログCMSや商品管理。初期一覧はServer Componentで速く出し、画像アップロードや公開WebhookはRoute Handlerに寄せます。DBの列を足したり消したりが絡むならDBマイグレーション運用の事故を防ぐの段階適用も先に読んでおくと安全です。

4つ目はBFF構成。フロントから複数の外部APIを直接叩かず、Next.js側のRoute Handlerに集約します。公式のBackend for Frontend guideの考え方そのままで、秘密鍵をブラウザに出さずに済みます。

僕がやらかした落とし穴3つ

正直に書きます。最初の数本は、だいたい同じ穴に落ちました。

ひとつ目は、冒頭の秘密情報の漏れ。初期表示が便利だからとClient Componentにロジックを寄せていったら、その先で DATABASE_URL を読むヘルパーまで一緒にブラウザ側へ引っ張られていました。直し方は単純で、サーバー専用ファイルの先頭に import "server-only"; を足すだけ。次から事故ろうとするとビルドが止まります。

ふたつ目は、初期表示なのに全部 useEffect でAPI取得させたこと。Claude Codeに「動く一覧を作って」とだけ頼むと、use client を付けてマウント後に取りに行くコードを書くことがあります。動くんですが、初回が遅いし、SEOにも弱い。「一覧の初期表示はServer Componentで await して取る」と最初に指定したら、素直に直りました。

みっつ目は、認証をUIだけで守ったつもりになっていたこと。「削除ボタンを管理者以外に出さなければ消されない」と思っていたら、Server Actionは直POSTで呼べると公式が明記している。つまりUIを隠しても、関数の中で requireUser() と所有者IDの確認をしていなければ素通りです。怖い話でした。

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 lintnpm run typecheck、フォームの手動送信、未ログイン時にRoute Handlerが401を返すかの確認まで。テストをどこから書くかで迷ったらClaude Codeで決めるテスト戦略の優先順位を参考にしてください。

よくある質問

Q. Server ActionとRoute Handler、どっちを使えばいいですか? 画面の中のフォーム更新ならServer Action、外部(モバイル・他社・Webhook)から叩くならRoute Handlerです。同じ保存処理でも、入口が「自分の画面」か「外部」かで分けると迷いません。両方から使う保存ロジックは lib に1本置いて、両者から呼びます。

Q. use client はどこに付ければいいですか? 状態(useState)、クリックなどのイベント、localStoragewindow などブラウザ専用APIが必要な部品だけです。use client を付けたファイルがimportするものは、まとめてブラウザ向けバンドルに入ります。だから「ページ全体に付ける」のではなく、対話が必要な小さい部品に絞るのがコツです。

Q. 秘密情報がブラウザに漏れていないか、どう確認しますか? まずサーバー専用ファイルの先頭に import "server-only"; を入れること。これでClient Componentからimportするとビルドが落ちます。加えて、ブラウザに出してよい環境変数は NEXT_PUBLIC_ が付いたものだけ、と覚えてください。付いていない変数はクライアント側では空になります。

Q. 保存したのに一覧が古いままです。 Server Actionの中で revalidatePath("/対象パス") を呼んでいるか確認してください。保存しただけではキャッシュは更新されません。タグ単位で管理しているなら revalidateTag を使います。

Q. Claude Codeに最初に何を渡せば安定しますか? この記事の境界表とファイル構成、そして「Client Componentに秘密情報・DB・env を入れない」「一覧の初期表示はServer Componentで取る」の2つの禁止/原則です。これを先に渡すだけで、後からの戻し作業がはっきり減ります。

実際に試した結果

冒頭の「秘密情報がブラウザに漏れかけた」事件のあと、僕はやり方を変えました。Claude Codeに作らせる前に、必ず境界表とファイル構成を貼る。サーバー専用ファイルには問答無用で server-only を入れる。それだけです。

結果、修正依頼の中身が変わりました。以前は「DBクライアントがクライアント側に入っている」「初期表示が全部 useEffect」みたいな構造の事故が多かった。境界を先に言語化してからは、戻しの大半が「ボタンの文言」「余白の調整」みたいなUIの細かい話に寄りました。server-only を入れてからは、危ない経路を書こうとした瞬間にビルドが落ちるので、夜中にヒヤッとすることもなくなりました。

逆に、急いでいて最初の指示を「いい感じに作って」で済ませた回は、やっぱりフォーム処理とAPI処理が混ざって、レビューで戻す時間が膨らみました。教訓ははっきりしています。Next.jsのフルスタック開発では、実装速度より先に「どこがサーバーで、どこがブラウザか」を言葉にするのが、いちばん速い近道です。

チームで標準化したい場合は、ClaudeCodeLabのClaude Code教材・テンプレート集に、この境界ルールとレビュー観点をまとめたテンプレートを用意しています。自社のリポジトリ規約に合わせて整えたいときに使ってください。

#Claude Code #Next.js #フルスタック #App Router #Server Actions
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。