Claude Code e Next.js full-stack: guia prático de App Router
Guia prático para usar Claude Code com Next.js App Router: limites servidor/cliente, Server Actions, APIs, validação, auth e revisão.
Claude Code consegue gerar uma funcionalidade full-stack em Next.js muito rápido. Porém, essa velocidade só ajuda se os limites do App Router estiverem claros. Com um prompt vago, ele pode misturar lógica de servidor em componentes do navegador, transformar páginas demais em Client Components ou salvar JSON sem validação.
Este guia usa um pequeno painel autenticado de tarefas como exemplo. Vamos passar por estrutura de projeto, Server Components versus Client Components, Route Handlers, Server Actions, validação, variáveis de ambiente, fronteira de autenticação e um prompt de revisão para Claude Code.
Use a documentação oficial como base: Next.js App Router docs, Server and Client Components, Route Handlers, Mutating Data e Backend for Frontend guide. Para o fluxo do agente, veja Claude Code common workflows.
Defina os limites primeiro
No App Router, a parte difícil não é decorar nomes, mas saber onde cada código roda. Um Server Component é renderizado no servidor. Um Client Component roda no navegador e é usado para estado, eventos e APIs do browser. Uma Server Action é uma função de mutação no servidor, normalmente chamada por um formulário. Um Route Handler é um endpoint HTTP para APIs JSON, webhooks ou clientes externos. BFF significa Backend for Frontend: uma camada backend fina pensada para uma interface específica.
| Área | Quando usar | O que pode conter | Instrução para Claude Code |
|---|---|---|---|
| Server Component | Render inicial, leitura de DB, SEO | DB, auth, APIs privadas | Manter server-first salvo interação real |
| Client Component | Formulários, modais, abas, UI otimista | useState, useActionState, eventos | Não importar secrets, DB nem server-only |
| Server Action | Criar, atualizar, apagar pela UI | Validação, auth, mutação, revalidação | Não usar como API pública |
| Route Handler | API externa, webhook, mobile client | JSON, status HTTP, assinatura | Validar entrada e exigir auth |
Entregue esta tabela ao Claude Code antes de implementar. A regra central é não expor segredos em Client Components. Somente variáveis com prefixo NEXT_PUBLIC_ devem ir para o navegador.
flowchart TD
Browser[Formulario do navegador] --> Client[Client Component]
Client --> Action[Server Action]
External[Servico externo] --> Route[Route Handler]
Page[Server Component] --> Auth[Fronteira de auth]
Action --> Auth
Route --> Auth
Auth --> Data[DB ou logica de servidor]
Data --> Page
Estrutura do projeto
Peça para Claude Code trabalhar com uma estrutura explícita. No App Router, o sistema de arquivos define as rotas; sem isso, a lógica costuma se espalhar.
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 a tela inicial, task-create-form.tsx cuida da interação no navegador, actions.ts processa mutações da UI, route.ts expõe HTTP e lib guarda lógica de servidor.
Lógica somente de servidor
O armazenamento abaixo é em memória para facilitar a cópia em um projeto de demonstração. Em produção, troque por Prisma, Drizzle, Supabase ou sua camada de dados. O ponto principal é server-only, que evita importação acidental em 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;
}
A autenticação também fica do lado do servidor. Este exemplo trata um cookie demo_user_id como usuário logado. Em produto real, substitua por Auth.js, Clerk ou seu provedor 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();
}
Variáveis de ambiente devem ser centralizadas e validadas. Não importe este arquivo em Client Components.
// 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 com Server Component
A lista de tarefas é um Server Component. Ela lê dados autenticados no servidor, reduz o bundle do navegador e evita carregamento inicial desnecessário no cliente.
// 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>
);
}
Instrua Claude Code a não adicionar use client neste arquivo, a menos que exista uma interação real de navegador.
Mutação com Server Action
Server Actions funcionam bem para mutações de formulário. Mantenha autenticação, validação, escrita e revalidação na mesma função. Zod valida a entrada antes da camada de dados.
// 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.",
};
}
O Client Component cuida apenas do estado do navegador e da renderização do formulário.
// 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 com Route Handler
Use Route Handler para integrações externas, webhooks, clientes móveis ou APIs JSON. Server Actions são ótimas para UI, mas não substituem um 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áticos
O primeiro caso é um painel interno de solicitações. A lista fica em Server Component, a criação em Server Action e notificações externas em Route Handler.
O segundo é uma tela de configuração SaaS. Cobrança, convites de equipe e criação de API keys devem manter a UI pequena e checar permissões nas Server Actions.
O terceiro é um CMS ou admin de produtos. A lista inicial é renderizada no servidor, enquanto uploads e webhooks ficam em Route Handlers. Para mudanças de banco, veja a automação de migrations.
O quarto é uma arquitetura BFF. Em vez de chamar várias APIs de terceiros do navegador, agrupe-as atrás de Route Handlers do Next.js para manter secrets no servidor.
Armadilhas concretas
O maior risco é deixar Claude Code borrar a fronteira. Verifique se algum Client Component importa helper de DB, se o carregamento inicial foi movido sem necessidade para useEffect, se uma Server Action virou API pública ou se um Route Handler salva request.json() sem validação.
Autenticação precisa ser aplicada no servidor. Esconder botão não é autorização. Server Actions e Route Handlers devem verificar usuário, dono do recurso e papel. Para mais detalhes, veja o guia de autenticação.
Evite prompts vagos como “corrija do jeito certo”. Uma pequena mudança em App Router pode afetar cache, revalidação, estado do formulário e auth. Passe sempre escopo, mudanças proibidas e comandos de verificação.
Prompt de revisão para Claude Code
Depois de implementar, peça revisão antes de permitir edições.
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.
Depois corrija um ponto por vez. No mínimo, rode lint, typecheck, testes relevantes, envio manual do formulário e uma chamada não autenticada à API que deve retornar 401. Para ampliar, veja o guia de estratégia de testes.
Treinamento e templates ClaudeCodeLab
Para projeto solo, a estrutura e o prompt deste artigo já ajudam bastante. Em equipe, transforme regras de fronteira, checklist de revisão, auth e política de ambiente em templates reutilizáveis.
ClaudeCodeLab oferece Claude Code training and templates e consultation for team adoption. Use quando quiser padronizar telas administrativas Next.js, CMS, configurações SaaS e loops de revisão conforme as regras do seu repositório.
Resumo
Claude Code acelera o desenvolvimento full-stack com Next.js, mas só funciona bem quando a fronteira entre servidor e navegador vem primeiro. Use Server Components para render inicial, Client Components para interação, Server Actions para mutações da UI e Route Handlers para APIs externas.
Ao testar o fluxo deste artigo, o maior ganho não foi gerar mais código, e sim reduzir ruído na revisão. Quando a tabela de limites e a estrutura de arquivos foram passadas antes para Claude Code, os ajustes restantes ficaram em copy de UI e pequenos comportamentos. Com um prompt inicial vago, lógica de formulário e API se misturaram, e a revisão demorou mais que a implementação.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.