Use Cases (Updated: 6/1/2026)

Claude Code and Next.js Full-Stack Development: Practical App Router Guide

A practical Claude Code and Next.js guide covering App Router boundaries, Server Actions, APIs, auth, validation, and review loops.

Claude Code and Next.js Full-Stack Development: Practical App Router Guide

Claude Code can generate a Next.js full-stack feature quickly, but speed is only useful when the App Router boundaries are clear. If the prompt is vague, it may mix server-only logic into browser components, turn everything into a Client Component, or save unvalidated request bodies directly to your database.

This guide uses a small authenticated task dashboard as the running example. You will see a practical App Router structure, when to use Server Components versus Client Components, how to write Route Handlers and Server Actions, where validation and environment variables belong, how to keep the auth boundary on the server, and how to ask Claude Code for a serious architecture review.

For the official baseline, keep the Next.js App Router docs, Server and Client Components, Route Handlers, Mutating Data, and the Backend for Frontend guide open. For Claude Code workflow expectations, refer to Claude Code common workflows.

Decide Boundaries First

In App Router, the hard part is not memorizing feature names. It is knowing where each piece of code runs. A Server Component is rendered on the server. A Client Component runs in the browser and is required for state, events, and browser APIs. A Server Action is a server-side mutation function usually called from a form. A Route Handler is an HTTP endpoint for JSON APIs, webhooks, or external clients. BFF means Backend for Frontend: a thin backend layer designed for the needs of one UI.

AreaUse it forAllowed insideInstruction for Claude Code
Server ComponentInitial page render, DB reads, SEO pagesDB access, auth checks, private API callsKeep pages server-first unless interaction requires otherwise
Client ComponentForms, modals, tabs, optimistic UIuseState, useActionState, event handlersNever import secrets, DB clients, or server-only modules
Server ActionCreate, update, delete from the UIValidation, auth, mutation, revalidationDo not treat it as a public API
Route HandlerExternal API, webhook, mobile clientJSON response, status code, signature checksAlways validate input and enforce auth

Give this table to Claude Code before implementation. The most important rule is simple: do not expose secrets in Client Components. Only variables prefixed with NEXT_PUBLIC_ are intended for the browser. Database URLs, API keys, and auth secrets belong in server-only files.

flowchart TD
  Browser[Browser form] --> Client[Client Component]
  Client --> Action[Server Action]
  External[External service] --> Route[Route Handler]
  Page[Server Component] --> Auth[Auth boundary]
  Action --> Auth
  Route --> Auth
  Auth --> Data[DB or server-only logic]
  Data --> Page

Project Structure

Ask Claude Code to create or modify files in a predictable layout. Because App Router uses the filesystem as the routing model, vague prompts often scatter logic across app, components, and 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

This maps each responsibility to one place: page.tsx renders the initial UI, task-create-form.tsx handles browser interaction, actions.ts mutates data from the UI, route.ts exposes an API, and lib contains server-only application logic.

Server-Only Logic

The following storage layer is intentionally small and in-memory so you can paste it into a demo project. Replace it with Prisma, Drizzle, Supabase, or your database layer in production. The important part is server-only, which prevents accidental imports from Client Components.

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

Here is a minimal auth boundary. It treats a demo_user_id cookie as a logged-in user so the example is runnable. In a real product, replace this with Auth.js, Clerk, your session service, or an internal identity provider.

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

Environment variables should also be centralized and validated. Never import this file into a Client Component.

// 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 Page

The task list is a Server Component. It reads authenticated data on the server, keeps the browser bundle smaller, and avoids a client-side loading waterfall.

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

Tell Claude Code explicitly not to add use client to this file unless it identifies a real browser interaction that belongs here.

Server Action Mutation

Server Actions are a good fit for form mutations. Keep auth, validation, persistence, and revalidation together. Zod is used here to validate form input before anything touches the data layer.

// 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.",
  };
}

The Client Component owns only browser state and form rendering. It imports the Server Action, but it does not import the database, auth secret, or environment module.

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

Use a Route Handler for external integrations, webhooks, mobile clients, or public JSON APIs. Server Actions are convenient for UI mutations, but they are not a replacement for a clear HTTP contract.

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

Practical Use Cases

An internal request dashboard is the simplest fit. The list page stays server-rendered, request creation uses a Server Action, and notifications to Slack or another tool go through a Route Handler.

A SaaS settings area is another strong use case. Billing settings, team invitations, and API key creation should keep the interactive UI small while enforcing permission checks inside Server Actions.

A content CMS or product admin also maps well to this structure. The initial list can be rendered on the server, image or webhook integrations can live in Route Handlers, and database changes can be planned with the DB migration automation guide.

For a BFF architecture, put third-party API calls behind Next.js Route Handlers instead of calling every provider directly from the browser. This keeps secrets server-side and gives the UI a stable contract.

Concrete Pitfalls

The biggest risk is letting Claude Code blur the boundary. Watch for a Client Component importing a database helper, initial data loading moved into useEffect without need, a Server Action used like a public API, or a Route Handler that stores request.json() without validation.

Authentication must also be enforced on the server. Hiding a button in the UI is not authorization. Server Actions and Route Handlers both need user checks, ownership checks, and role checks where relevant. For more detail, see the authentication implementation guide.

Finally, avoid vague repair prompts such as “fix it nicely.” App Router changes can affect caching, revalidation, form state, and auth. Give Claude Code the changed files, forbidden changes, and verification commands every time.

Claude Code Review Prompt

After implementation, ask for a review before edits. This catches architectural mistakes before the agent starts rewriting 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.

Then fix one finding at a time. At minimum, run linting, type checks, relevant unit tests, a manual form submission, and an unauthenticated API request that should return 401. For broader planning, use the testing strategy guide.

ClaudeCodeLab CTA

For a solo project, the structure and prompt in this article are enough to start. For a team, turn the boundary rules, review checklist, auth rules, and environment-variable policy into reusable templates.

ClaudeCodeLab provides Claude Code training and templates and consultation for team adoption. Use them when you want a repeatable workflow for Next.js admin screens, CMS features, SaaS settings, and review loops that match your repository rules.

Summary

Claude Code can make Next.js full-stack development much faster, but only when you define the server and browser boundary first. Use Server Components for initial rendering, Client Components for interaction, Server Actions for UI mutations, and Route Handlers for external APIs.

After trying the workflow in this article, the biggest improvement was not the amount of generated code. It was the reduction in review noise. When the boundary table and file structure were given to Claude Code first, the remaining edits were mostly UI copy and small behavior changes. When the first prompt was vague, form logic and API logic mixed together, and the review took longer than the implementation.

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

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.