Use Cases (Aktualisiert: 1.6.2026)

Claude Code und Next.js Full-Stack: praktischer App-Router-Leitfaden

Praxisguide für Claude Code mit Next.js App Router: Server/Client-Grenzen, Server Actions, APIs, Validierung, Auth und Review.

Claude Code und Next.js Full-Stack: praktischer App-Router-Leitfaden

Claude Code kann eine Next.js-Full-Stack-Funktion sehr schnell erzeugen. Nützlich ist das aber nur, wenn die Grenzen im App Router klar sind. Bei einer vagen Anweisung kann serverseitige Logik in Browser-Komponenten landen, jede Seite wird unnötig zum Client Component, oder ungeprüftes JSON wird direkt gespeichert.

Dieser Leitfaden nutzt ein kleines authentifiziertes Task-Dashboard als Beispiel. Du siehst eine App-Router-Struktur, die Entscheidung zwischen Server Components und Client Components, Route Handlers, Server Actions, Validierung, Umgebungsvariablen, Auth-Grenzen und einen Review-Prompt für Claude Code.

Als offizielle Grundlage dienen Next.js App Router docs, Server and Client Components, Route Handlers, Mutating Data und der Backend for Frontend guide. Für den Agenten-Workflow siehe Claude Code common workflows.

Grenzen zuerst festlegen

Bei App Router ist nicht der Funktionsname das Schwierige, sondern der Ausführungsort. Ein Server Component wird auf dem Server gerendert. Ein Client Component läuft im Browser und ist nötig für State, Events oder Browser-APIs. Eine Server Action ist eine serverseitige Mutationsfunktion, oft aus einem Formular heraus. Ein Route Handler ist ein HTTP-Endpunkt für JSON-APIs, Webhooks oder externe Clients. BFF bedeutet Backend for Frontend: eine dünne Backend-Schicht für eine konkrete Oberfläche.

BereichEinsatzDarf enthaltenAnweisung an Claude Code
Server ComponentInitiales Rendern, DB-Lesen, SEO-SeitenDB-Zugriff, Auth, private APIsStandardmäßig server-first lassen
Client ComponentFormulare, Modals, Tabs, optimistische UIuseState, useActionState, EventsKeine Secrets, DB-Clients oder server-only Module
Server ActionErstellen, Aktualisieren, Löschen aus der UIValidierung, Auth, Mutation, RevalidationNicht als öffentliche API nutzen
Route HandlerExterne API, Webhook, Mobile ClientJSON, HTTP-Status, SignaturprüfungInput validieren und Auth erzwingen

Gib Claude Code diese Tabelle vor der Implementierung. Die wichtigste Regel lautet: keine Secrets in Client Components. Nur Variablen mit NEXT_PUBLIC_ sind für den Browser gedacht.

flowchart TD
  Browser[Browser-Formular] --> Client[Client Component]
  Client --> Action[Server Action]
  External[Externer Dienst] --> Route[Route Handler]
  Page[Server Component] --> Auth[Auth-Grenze]
  Action --> Auth
  Route --> Auth
  Auth --> Data[DB oder Serverlogik]
  Data --> Page

Projektstruktur

Bitte Claude Code zuerst um eine feste Dateistruktur. Im App Router ist das Dateisystem das Routing-Modell. Ohne Strukturvorgabe verteilt sich Logik schnell über app, components und 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 rendert die Startansicht, task-create-form.tsx enthält Browser-Interaktion, actions.ts verarbeitet UI-Mutationen, route.ts bietet HTTP an und lib enthält serverseitige Anwendungslogik.

Server-only Logik

Der folgende Speicher ist absichtlich nur in-memory, damit das Beispiel direkt kopierbar bleibt. In Produktion ersetzt du ihn durch Prisma, Drizzle, Supabase oder deine Datenzugriffsschicht. Entscheidend ist server-only, damit Client Components dieses Modul nicht versehentlich importieren.

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

Auch die Auth-Grenze bleibt serverseitig. Dieses Demo behandelt ein Cookie demo_user_id als eingeloggten Benutzer. In echten Produkten ersetzt du das durch Auth.js, Clerk oder deinen 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();
}

Umgebungsvariablen sollten zentral validiert werden. Importiere diese Datei nie in einem 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);

Seite als Server Component

Die Task-Liste ist ein Server Component. Authentifizierte Daten werden auf dem Server gelesen, das Browser-Bundle bleibt kleiner und unnötige Client-Ladezustände entfallen.

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

Sage Claude Code ausdrücklich, dass use client hier nur bei echter Browser-Interaktion erlaubt ist.

Mutation mit Server Action

Server Actions passen gut zu Formularmutationen. Auth, Validierung, Speichern und Revalidation bleiben in einer Funktion. Zod prüft die Eingabe, bevor Daten gespeichert werden.

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

Das Client Component enthält nur Browser-State und Formularausgabe.

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

API mit Route Handler

Für externe Integrationen, Webhooks, mobile Clients oder JSON-APIs nutzt du Route Handlers. Server Actions sind bequem für UI-Mutationen, ersetzen aber keinen klaren HTTP-Vertrag.

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

Praktische Use Cases

Der erste Use Case ist ein internes Anfrage-Dashboard. Die Liste bleibt Server Component, das Erstellen läuft über Server Action, externe Benachrichtigungen über Route Handler.

Der zweite Use Case ist ein SaaS-Einstellungsbereich. Billing, Team-Einladungen und API-Key-Erstellung sollten eine kleine UI haben und Berechtigungen in Server Actions prüfen.

Der dritte Use Case ist ein CMS oder Produkt-Admin. Die erste Liste wird serverseitig gerendert, Uploads und Webhooks liegen in Route Handlers. Für Datenbankänderungen passt der Leitfaden zur DB-Migrationsautomatisierung.

Der vierte Use Case ist eine BFF-Architektur. Statt mehrere Drittanbieter-APIs direkt aus dem Browser aufzurufen, bündelst du sie hinter Next.js Route Handlers und hältst Secrets serverseitig.

Konkrete Fallen

Das größte Risiko ist eine verwischte Grenze. Prüfe, ob ein Client Component DB-Helfer importiert, ob initiales Laden unnötig in useEffect verschoben wurde, ob eine Server Action als öffentliche API dient oder ob ein Route Handler request.json() ohne Validierung speichert.

Auth muss serverseitig erzwungen werden. Ein versteckter Button ist keine Autorisierung. Server Actions und Route Handlers müssen Benutzer, Eigentümer und Rollen prüfen. Mehr dazu steht im Auth-Implementierungsleitfaden.

Vermeide außerdem vage Prompts wie “mach es sauber”. App-Router-Änderungen können Cache, Revalidation, Formularzustand und Auth betreffen. Gib Claude Code jedes Mal Scope, verbotene Änderungen und Prüfkommandos.

Review-Prompt für Claude Code

Nach der Implementierung solltest du zuerst nur Review anfordern, keine Änderungen.

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.

Korrigiere danach jeweils nur einen Befund. Mindestens prüfen: lint, typecheck, relevante Tests, manuelles Formular-Submit und ein nicht authentifizierter API-Aufruf, der 401 liefern muss. Für mehr Testtiefe siehe den Testing-Strategie-Leitfaden.

ClaudeCodeLab Training und Templates

Für ein Solo-Projekt reichen Struktur und Prompt aus diesem Artikel. In Teams sollten Grenzregeln, Review-Checklist, Auth-Regeln und Environment-Policy als Templates vorliegen.

ClaudeCodeLab bietet Claude Code training and templates und consultation for team adoption. Das hilft, Next.js-Admin-Oberflächen, CMS, SaaS-Einstellungen und Review-Loops an die eigenen Repository-Regeln anzupassen.

Zusammenfassung

Claude Code beschleunigt Next.js Full-Stack-Entwicklung, aber nur mit klarer Server-Browser-Grenze. Server Components für initiales Rendering, Client Components für Interaktion, Server Actions für UI-Mutationen, Route Handlers für externe APIs.

Beim Testen dieses Ablaufs war der größte Gewinn nicht mehr generierter Code, sondern weniger Review-Rauschen. Wenn Grenztabelle und Dateistruktur zuerst an Claude Code gingen, blieben meist UI-Texte und kleine Verhaltensthemen übrig. Bei einem vagen Anfangsprompt vermischten sich Formular- und API-Logik, und die Review dauerte länger als die Implementierung.

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

Kostenloses PDF: Claude-Code-Cheatsheet

E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.

Wir schützen Ihre Daten und senden keinen Spam.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.