Claude Code et Next.js full-stack : guide pratique App Router
Guide pratique pour utiliser Claude Code avec Next.js App Router : limites serveur/client, Server Actions, API, validation, auth et review.
Claude Code peut générer très vite une fonctionnalité full-stack en Next.js. Mais cette vitesse n’a de valeur que si les limites d’App Router sont explicites. Avec une consigne floue, l’agent peut mettre de la logique serveur dans un composant navigateur, transformer trop de fichiers en Client Components, ou écrire en base des données JSON non validées.
Ce guide prend comme exemple un petit tableau de bord authentifié pour créer des tâches. Il couvre la structure App Router, le choix entre Server Components et Client Components, les Route Handlers, les Server Actions, la validation, les variables d’environnement, la frontière d’authentification et la boucle de review avec Claude Code.
Gardez les références officielles sous la main : Next.js App Router docs, Server and Client Components, Route Handlers, Mutating Data, Backend for Frontend guide. Pour Claude Code, consultez Claude Code common workflows.
Définir les limites d’abord
Avec App Router, la difficulté principale n’est pas le nom des fonctions, mais l’endroit où le code s’exécute. Un Server Component est rendu sur le serveur. Un Client Component s’exécute dans le navigateur et sert aux états, événements et APIs navigateur. Une Server Action est une fonction de mutation côté serveur, souvent appelée depuis un formulaire. Un Route Handler est un endpoint HTTP pour API JSON, webhook ou client externe. BFF signifie Backend for Frontend : une couche backend fine conçue pour une interface.
| Zone | Usage | Ce qu’elle peut contenir | Instruction pour Claude Code |
|---|---|---|---|
| Server Component | Rendu initial, lecture DB, pages SEO | Accès DB, auth, API privées | Garder server-first sans interaction réelle |
| Client Component | Formulaires, modales, onglets, UI optimiste | useState, useActionState, événements | Ne jamais importer secrets, DB ou server-only |
| Server Action | Créer, modifier, supprimer depuis l’UI | Validation, auth, mutation, revalidation | Ne pas utiliser comme API publique |
| Route Handler | API externe, webhook, client mobile | JSON, codes HTTP, vérification de signature | Valider l’entrée et appliquer l’auth |
Donnez ce tableau à Claude Code avant de coder. La règle essentielle : ne jamais exposer de secrets dans un Client Component. Seules les variables préfixées par NEXT_PUBLIC_ sont destinées au navigateur.
flowchart TD
Browser[Formulaire navigateur] --> Client[Client Component]
Client --> Action[Server Action]
External[Service externe] --> Route[Route Handler]
Page[Server Component] --> Auth[Frontière auth]
Action --> Auth
Route --> Auth
Auth --> Data[DB ou logique serveur]
Data --> Page
Structure du projet
Demandez à Claude Code de respecter une structure explicite. Dans App Router, l’arborescence est le modèle de routage ; si elle n’est pas fixée, la logique se disperse vite.
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 affiche l’état initial, task-create-form.tsx gère l’interaction navigateur, actions.ts exécute les mutations de l’UI, route.ts expose HTTP, et lib contient la logique serveur.
Logique serveur uniquement
Le stockage suivant est volontairement en mémoire pour une démo copiable. En production, remplacez-le par Prisma, Drizzle, Supabase ou votre couche de données. Le point important est server-only, qui empêche l’import depuis un 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;
}
La frontière d’authentification reste également côté serveur. Cette démo considère un cookie demo_user_id comme un utilisateur connecté. En production, utilisez Auth.js, Clerk ou votre service d’identité.
// 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();
}
Les variables d’environnement doivent être centralisées et validées. N’importez jamais ce fichier dans un 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);
Page en Server Component
La liste des tâches est un Server Component. Elle lit les données authentifiées côté serveur, réduit le bundle navigateur et évite un chargement client inutile.
// 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>
);
}
Précisez à Claude Code de ne pas ajouter use client à ce fichier sans besoin réel d’interaction navigateur.
Mutation avec Server Action
Les Server Actions conviennent très bien aux mutations de formulaires. Gardez l’authentification, la validation, l’écriture et la revalidation ensemble. Zod valide l’entrée avant tout accès à la couche de données.
// 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.",
};
}
Le Client Component ne garde que l’état navigateur et le rendu du formulaire.
// 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 avec Route Handler
Utilisez un Route Handler pour les intégrations externes, webhooks, clients mobiles ou APIs JSON. Une Server Action est pratique pour l’UI, mais elle ne remplace pas un contrat HTTP explicite.
// 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 });
}
Cas d’usage
Premier cas : un tableau de demandes internes. La liste reste en Server Component, la création passe par une Server Action et les notifications externes par un Route Handler.
Deuxième cas : une page de configuration SaaS. Facturation, invitations d’équipe et création de clés API doivent garder une UI légère et vérifier les droits dans les Server Actions.
Troisième cas : un CMS ou une administration produit. La liste initiale est rendue sur le serveur, tandis que les uploads et webhooks vivent dans des Route Handlers. Pour les changements DB, consultez le guide automatisation des migrations.
Quatrième cas : une architecture BFF. Au lieu d’appeler plusieurs APIs tierces depuis le navigateur, regroupez-les derrière Next.js pour garder les secrets côté serveur.
Pièges concrets
Le plus grand risque est de laisser Claude Code brouiller les frontières. Vérifiez qu’un Client Component n’importe pas un helper DB, que le chargement initial n’a pas été déplacé inutilement dans useEffect, qu’une Server Action n’est pas utilisée comme API publique et qu’un Route Handler ne stocke pas request.json() sans validation.
L’authentification doit être appliquée côté serveur. Cacher un bouton n’est pas une autorisation. Server Actions et Route Handlers doivent vérifier utilisateur, propriété et rôle. Pour approfondir, consultez le guide d’authentification.
Évitez aussi les prompts vagues comme “corrige proprement”. Un petit changement App Router peut toucher cache, revalidation, état de formulaire et auth. Donnez toujours les fichiers modifiés, les changements interdits et les commandes de vérification.
Prompt de review Claude Code
Après l’implémentation, demandez d’abord une review sans modification.
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.
Corrigez ensuite un point à la fois. Vérifiez au minimum lint, typecheck, tests pertinents, soumission manuelle du formulaire et appel API non authentifié qui doit renvoyer 401. Pour élargir la stratégie, consultez le guide de testing.
Formation et templates ClaudeCodeLab
Pour un projet solo, la structure et le prompt de cet article suffisent. En équipe, transformez les règles de frontière, la checklist de review, l’auth et la politique d’environnement en templates réutilisables.
ClaudeCodeLab propose des training and templates for Claude Code et une consultation for team adoption. C’est utile pour standardiser des écrans d’administration Next.js, CMS, réglages SaaS et boucles de review selon vos règles de dépôt.
Résumé
Claude Code accélère le full-stack Next.js, mais seulement si la frontière serveur/navigateur est définie en premier. Utilisez Server Components pour le rendu initial, Client Components pour l’interaction, Server Actions pour les mutations UI et Route Handlers pour les APIs externes.
Après avoir testé ce flux, le gain principal n’a pas été la quantité de code généré, mais la baisse du bruit en review. Quand la table des limites et la structure de fichiers étaient données d’abord à Claude Code, les corrections restantes concernaient surtout le texte d’UI et de petits comportements. Avec un prompt initial flou, la logique formulaire et API se mélangeait, et la review prenait plus de temps que l’implémentation.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.