Claude Code y Next.js full-stack: guía práctica de App Router
Guía práctica para usar Claude Code con Next.js App Router: límites servidor/cliente, Server Actions, APIs, validación, auth y revisión.
Claude Code puede crear una función full-stack de Next.js muy rápido, pero la velocidad solo ayuda si los límites de App Router están claros. Si el prompt es vago, puede mezclar lógica de servidor en componentes del navegador, convertir páginas enteras en Client Components o guardar cuerpos JSON sin validar.
Esta guía usa como ejemplo un pequeño panel autenticado para crear tareas. Verás una estructura de proyecto, cuándo usar Server Components y Client Components, cómo escribir Route Handlers y Server Actions, dónde poner validación y variables de entorno, cómo mantener la autenticación en el servidor y cómo pedir a Claude Code una revisión de arquitectura.
Para contrastar con la fuente oficial, consulta Next.js App Router docs, Server and Client Components, Route Handlers, Mutating Data y Backend for Frontend guide. Para el flujo de trabajo con Claude Code, usa Claude Code common workflows.
Define los límites primero
En App Router, lo importante no es memorizar nombres, sino saber dónde corre cada código. Un Server Component se renderiza en el servidor. Un Client Component corre en el navegador y se usa cuando necesitas estado, eventos o APIs del navegador. Un Server Action es una función de mutación del servidor, normalmente llamada desde un formulario. Un Route Handler es un endpoint HTTP para APIs JSON, webhooks o clientes externos. BFF significa Backend for Frontend: una capa backend delgada diseñada para una interfaz concreta.
| Área | Cuándo usarla | Qué puede contener | Instrucción para Claude Code |
|---|---|---|---|
| Server Component | Render inicial, lecturas de DB, páginas SEO | Acceso a DB, auth, APIs privadas | Mantener server-first salvo interacción real |
| Client Component | Formularios, modales, pestañas, UI optimista | useState, useActionState, eventos | No importar secretos, DB ni módulos server-only |
| Server Action | Crear, actualizar, borrar desde la UI | Validación, auth, mutación, revalidación | No usar como API pública |
| Route Handler | API externa, webhook, cliente móvil | JSON, códigos HTTP, verificación de firma | Validar entrada y exigir auth |
Entrega esta tabla a Claude Code antes de implementar. La regla principal es no exponer secretos en Client Components. Solo las variables con prefijo NEXT_PUBLIC_ deben llegar al navegador.
flowchart TD
Browser[Formulario del navegador] --> Client[Client Component]
Client --> Action[Server Action]
External[Servicio externo] --> Route[Route Handler]
Page[Server Component] --> Auth[Límite de auth]
Action --> Auth
Route --> Auth
Auth --> Data[DB o lógica de servidor]
Data --> Page
Estructura del proyecto
Pide a Claude Code que trabaje con una estructura explícita. En App Router, el sistema de archivos define las rutas; si el prompt no fija el diseño, la lógica suele dispersarse.
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 renderiza la pantalla inicial, task-create-form.tsx maneja la interacción del navegador, actions.ts ejecuta mutaciones desde la UI, route.ts expone HTTP y lib guarda lógica del servidor.
Lógica solo de servidor
El siguiente almacenamiento en memoria sirve para una demo copiable. En producción, cámbialo por Prisma, Drizzle, Supabase o tu capa de datos. Lo importante es server-only, que evita importaciones accidentales desde 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;
}
La autenticación también debe quedar en el servidor. Este ejemplo trata una cookie demo_user_id como usuario autenticado. En un producto real, reemplázalo por Auth.js, Clerk o tu proveedor interno.
// 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();
}
Centraliza y valida las variables de entorno. No importes este archivo desde 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);
Página con Server Component
La lista de tareas debe ser un Server Component. Lee datos autenticados en el servidor, reduce el bundle del navegador y evita cascadas de carga innecesarias.
// 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>
);
}
Indica a Claude Code que no añada use client a este archivo salvo que encuentre una interacción real del navegador.
Mutación con Server Action
Server Actions encajan bien con mutaciones de formularios. Mantén autenticación, validación, escritura y revalidación en la misma función. Zod valida la entrada antes de tocar la capa de datos.
// 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.",
};
}
El Client Component solo contiene estado del navegador y renderizado del formulario.
// 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 con Route Handler
Usa Route Handler para integraciones externas, webhooks, clientes móviles o APIs JSON. Server Actions son cómodas para la UI, pero no sustituyen un contrato HTTP claro.
// 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 });
}
Casos de uso prácticos
El primer caso es un panel interno de solicitudes. La lista va en Server Component, la creación en Server Action y las notificaciones a Slack u otra herramienta en Route Handler.
El segundo es una pantalla de configuración SaaS. Facturación, invitaciones de equipo y generación de API keys deben mantener la UI ligera y verificar permisos en Server Actions.
El tercero es un CMS o panel de productos. La lista inicial se renderiza en servidor, mientras que uploads, webhooks y publicación viven en Route Handlers. Si también cambias la base de datos, mira la guía de automatización de migraciones.
El cuarto es una arquitectura BFF. En vez de llamar múltiples APIs de terceros desde el navegador, agrúpalas detrás de Route Handlers para proteger secretos y dar a la UI un contrato estable.
Errores concretos
El mayor riesgo es dejar que Claude Code borre el límite servidor/cliente. Revisa si un Client Component importa helpers de DB, si la carga inicial se movió a useEffect sin necesidad, si un Server Action se usa como API pública o si un Route Handler guarda request.json() sin validación.
La autenticación también debe cumplirse en el servidor. Ocultar un botón no es autorización. Server Actions y Route Handlers deben comprobar usuario, propiedad del recurso y rol. Para profundizar, consulta la guía de autenticación.
Evita prompts vagos como “arréglalo bien”. En App Router, un cambio pequeño puede afectar caché, revalidación, estado del formulario y auth. Entrega siempre archivos modificados, cambios prohibidos y comandos de verificación.
Prompt de revisión para Claude Code
Después de implementar, pide revisión antes de permitir ediciones.
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.
Luego corrige un hallazgo cada vez. Como mínimo, ejecuta lint, typecheck, tests relevantes, una creación manual con el formulario y una llamada no autenticada al API que debe devolver 401. Para ampliar el plan, consulta la guía de estrategia de testing.
Formación y plantillas de ClaudeCodeLab
Para un proyecto personal, la estructura y el prompt de este artículo bastan para empezar. En equipo, conviene convertir las reglas de límites, checklist de review, auth y variables de entorno en plantillas reutilizables.
ClaudeCodeLab ofrece training and templates for Claude Code y consultation for team adoption. Úsalos si quieres estandarizar pantallas de administración Next.js, CMS, configuración SaaS y revisiones según las reglas de tu repositorio.
Resumen
Claude Code acelera el desarrollo full-stack con Next.js, pero solo si defines antes el límite entre servidor y navegador. Usa Server Components para render inicial, Client Components para interacción, Server Actions para mutaciones de UI y Route Handlers para APIs externas.
Después de probar este flujo, el mayor beneficio no fue generar más código, sino reducir ruido en la revisión. Cuando la tabla de límites y la estructura de archivos se entregaron primero a Claude Code, los cambios restantes fueron casi siempre copy de UI y ajustes pequeños. Con un primer prompt vago, la lógica de formulario y API se mezcló y la revisión tardó más que la implementación.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.