Use Cases (업데이트: 2026. 6. 1.)

Claude Code와 Next.js 풀스택 개발: App Router 실전 가이드

Claude Code로 Next.js App Router 풀스택 기능을 만들 때 경계 설계, Server Actions, API, 인증, 리뷰 루프를 다룹니다.

Claude Code와 Next.js 풀스택 개발: App Router 실전 가이드

Claude Code에 Next.js 풀스택 기능을 맡기면 화면, API, 폼 처리를 빠르게 만들 수 있습니다. 하지만 App Router의 경계를 먼저 정하지 않으면 서버 전용 코드가 브라우저 컴포넌트로 들어가거나, 모든 페이지가 Client Component로 바뀌거나, 검증되지 않은 JSON이 그대로 데이터 저장소로 들어갈 수 있습니다.

이 글은 로그인한 사용자가 작업을 생성하는 작은 대시보드를 예제로 삼습니다. App Router 구조, Server Components와 Client Components의 역할, Route Handlers, Server Actions, 입력 검증, 환경 변수, 인증 경계, Claude Code 리뷰 프롬프트까지 실무에서 바로 쓰는 흐름으로 정리합니다.

공식 기준은 Next.js App Router docs, Server and Client Components, Route Handlers, Mutating Data, Backend for Frontend guide를 확인하세요. Claude Code 작업 방식은 Claude Code common workflows가 기준입니다.

먼저 경계를 정하기

App Router에서 초보자가 가장 자주 헷갈리는 부분은 기능 이름보다 코드가 어디에서 실행되는지입니다. Server Component는 서버에서 렌더링되는 컴포넌트입니다. Client Component는 브라우저에서 실행되며 상태, 클릭, 브라우저 API가 필요할 때 사용합니다. Server Action은 폼에서 호출하는 서버 측 변경 함수입니다. Route Handler는 HTTP 엔드포인트이며 JSON API, Webhook, 외부 클라이언트에 적합합니다. BFF는 Backend for Frontend로, 화면을 위해 얇게 만든 백엔드 계층입니다.

영역사용 장면넣어도 되는 것Claude Code에 줄 지시
Server Component초기 표시, DB 읽기, SEO 페이지DB 접근, 인증 확인, 비공개 API 호출상호작용이 없으면 서버 우선으로 유지
Client Component폼, 모달, 탭, optimistic UIuseState, useActionState, 이벤트 처리비밀값, DB 클라이언트, server-only 모듈 금지
Server ActionUI에서 생성, 수정, 삭제검증, 인증, 변경, revalidate공개 API처럼 쓰지 않기
Route Handler외부 API, Webhook, 모바일 클라이언트JSON 응답, 상태 코드, 서명 검증입력 검증과 인증을 반드시 수행

이 표를 구현 전에 Claude Code에 전달하면 결과가 안정됩니다. 특히 Client Component에 secret을 노출하지 않는다고 명시해야 합니다. 브라우저에 노출해도 되는 환경 변수는 NEXT_PUBLIC_ 접두사가 붙은 값뿐입니다.

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는 파일 구조가 곧 라우팅 구조입니다. Claude Code에 먼저 파일 배치를 지정하면 로직이 app, components, lib에 뒤섞이는 일을 줄일 수 있습니다.

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는 브라우저 상호작용, actions.ts는 UI에서 발생한 변경, route.ts는 외부 HTTP API, lib는 서버 전용 로직을 담당합니다.

서버 전용 로직 분리

아래 저장소는 데모용 인메모리 구현입니다. 운영에서는 Prisma, Drizzle, Supabase 또는 기존 DB 계층으로 바꾸세요. 핵심은 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;
}

인증 경계도 서버 쪽에 둡니다. 이 예제는 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();
}

환경 변수는 한 곳에서 검증합니다. 이 파일은 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에 둡니다. 인증과 데이터 읽기를 서버에서 처리하면 브라우저 번들이 작아지고 불필요한 클라이언트 로딩 흐름을 피할 수 있습니다.

// 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">Tasks</h1>
          <p className="text-sm text-gray-600">Work owned by {user.name}</p>
        </div>
        <Link className="rounded bg-black px-4 py-2 text-white" href="/dashboard/tasks/new">
          New task
        </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">
                Priority: {task.priority} / Due: {task.dueDate ?? "none"}
              </p>
            </div>
          </li>
        ))}
      </ul>
    </main>
  );
}

Claude Code에는 “실제 브라우저 상호작용이 필요하지 않으면 이 파일에 use client를 붙이지 말라”고 적어두세요.

Server Action으로 변경 처리

폼 변경은 Server Action에 모으는 것이 좋습니다. 인증, 입력 검증, 저장, revalidate를 한 함수 안에서 순서대로 처리합니다.

// 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, "Title is required").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: "Please check the form fields.",
    };
  }

  await createTask({
    ownerId: user.id,
    ...parsed.data,
  });

  revalidatePath("/dashboard/tasks");

  return {
    ok: true,
    message: "Task created.",
  };
}

Client Component는 브라우저 상태와 폼 렌더링만 담당합니다.

// 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 ? "Creating..." : "Create"}
    </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">
          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">
          Priority
        </label>
        <select className="mt-1 w-full rounded border px-3 py-2" id="priority" name="priority">
          <option value="normal">Normal</option>
          <option value="high">High</option>
          <option value="low">Low</option>
        </select>
      </div>

      <div>
        <label className="block text-sm font-medium" htmlFor="dueDate">
          Due date
        </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">Create a task</h1>
      <TaskCreateForm />
    </main>
  );
}

Route Handler로 외부 API 만들기

외부 서비스, Webhook, 모바일 클라이언트, 공개 JSON API에는 Route Handler를 사용합니다. Server Action은 UI 내부 변경에는 편하지만 명확한 HTTP 계약을 대체하지는 않습니다.

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

실무 유스케이스

첫 번째는 사내 요청 대시보드입니다. 목록은 Server Component, 요청 생성은 Server Action, 외부 알림은 Route Handler로 나누면 책임이 섞이지 않습니다.

두 번째는 SaaS 설정 화면입니다. 결제 설정, 팀 초대, API 키 발급은 UI를 작게 유지하고 Server Action에서 권한을 확인하는 방식이 안전합니다.

세 번째는 CMS 또는 상품 관리 화면입니다. 목록은 서버에서 렌더링하고, 이미지 업로드나 공개 Webhook은 Route Handler로 분리합니다. DB 변경까지 포함한다면 DB 마이그레이션 자동화도 함께 보세요.

네 번째는 BFF 구조입니다. 프런트엔드가 여러 외부 API를 직접 호출하지 않고 Next.js Route Handler 뒤에 숨기면 secret을 브라우저에 노출하지 않고 안정적인 계약을 만들 수 있습니다.

자주 생기는 문제

가장 위험한 문제는 Claude Code가 경계를 흐리는 것입니다. Client Component가 DB helper를 import했는지, 초기 데이터 로딩이 불필요하게 useEffect로 옮겨졌는지, Server Action이 공개 API처럼 쓰이는지, Route Handler가 검증 없이 request.json()을 저장하는지 확인하세요.

인증은 UI 숨김으로 끝나지 않습니다. Server Action과 Route Handler 모두 사용자, 소유권, 역할을 확인해야 합니다. 인증 설계는 인증 구현 가이드를 참고하세요.

또한 “좋게 고쳐줘” 같은 모호한 프롬프트를 반복하지 마세요. App Router의 작은 수정도 캐시, revalidate, 폼 상태, 인증 경계에 영향을 줍니다. 매번 변경 범위, 금지 사항, 검증 명령을 함께 전달해야 합니다.

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.

이후에는 하나씩 작게 수정합니다. 최소한 lint, 타입 체크, 관련 단위 테스트, 폼 수동 제출, 미로그인 API 요청의 401 응답을 확인하세요. 더 넓은 테스트 계획은 테스트 전략 가이드를 참고하면 됩니다.

ClaudeCodeLab 교육과 템플릿

개인 프로젝트라면 이 글의 구조와 프롬프트만으로도 충분히 시작할 수 있습니다. 팀에서는 경계 규칙, 리뷰 체크리스트, 인증 규칙, 환경 변수 정책을 템플릿으로 만들어야 반복 작업이 안정됩니다.

ClaudeCodeLab은 Claude Code training and templatesteam adoption consultation을 제공합니다. Next.js 관리자 화면, CMS, SaaS 설정 화면, 리뷰 루프를 팀 규칙에 맞게 표준화하고 싶을 때 활용하세요.

정리

Claude Code는 Next.js 풀스택 개발을 빠르게 만들 수 있습니다. 다만 먼저 서버와 브라우저의 경계를 정해야 합니다. Server Component는 초기 렌더링, Client Component는 상호작용, Server Action은 UI 변경, Route Handler는 외부 API라는 기준을 유지하세요.

이 글의 흐름을 실제로 적용해 보니 가장 큰 효과는 생성 코드의 양이 아니라 리뷰 소음이 줄어든 점이었습니다. 경계표와 파일 구조를 먼저 전달했을 때는 이후 수정이 대부분 UI 문구와 작은 동작 조정에 그쳤습니다. 반대로 첫 지시가 모호하면 폼 로직과 API 로직이 섞이고, 구현보다 리뷰 시간이 더 길어졌습니다.

#Claude Code #Next.js #full-stack #App Router #Server Components
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.