Next.js App Router Boundaries: Ship Server Actions Without Accidents
Design Server/Client boundaries and Server Actions in Next.js App Router. Copy-paste types that stop secret leaks and stray use client.
“Hey Claude Code, throw together a task management screen in Next.js.”
I asked for exactly that, and five minutes later I had a working screen. The form, the list, all of it looked perfect. I got excited, gave it a half-hearted review, and merged.
That night, something nagged at me. The component that rendered the list had "use client" at the top, and it was importing a helper that read DATABASE_URL. In other words, a connection string that should only ever live on the server now had a clear path into the bundle shipped to the browser.
My stomach dropped.
How does a supposedly smart AI make such a rookie mistake? The reason is simple: App Router lets you write code without ever deciding where it runs. If you do not draw the line between server and browser, humans and AIs fall into the same hole. Today I want to share the pattern I now use to decide that boundary up front, so I can hand the work to Claude Code safely. Everything is at copy-paste granularity.
The running example is a small admin screen where a logged-in user creates tasks.
Key takeaways
- App Router becomes far easier to review the moment you split work into four roles: Server Component (server render), Client Component (browser behavior), Server Action (in-page mutation), and Route Handler (outward-facing API).
- The scariest accident is leaking secrets to the browser. Enforce
server-onlyplus the rule that “onlyNEXT_PUBLIC_-prefixed env vars are browser-safe,” and you close that door. - Auth cannot be enforced by hiding UI. Both Server Actions and Route Handlers must verify login and permissions inside the function every time, because a Server Function can be invoked by a direct POST.
- Give Claude Code the boundary table and file layout first. Vague instructions mix form handling with API handling and create rework.
- How to pick a rendering strategy (SSR/SSG/ISR) is a separate topic. This article focuses only on splitting server and browser responsibilities.
This article assumes Next.js App Router (v16 line) as of June 2026. For the official primary sources: the overview is in the Next.js App Router docs, the boundary story is in Server and Client Components, and mutations are in Mutating Data. How to choose a rendering strategy itself is covered separately in CSR vs SSR vs SSG vs ISR: how to choose, so here I stay focused on responsibility splitting.
Draw the “where” line, not the “feature” line
What trips people up when they start with App Router is not the names Server Action or Route Handler. It is that “does this code run on the server or in the browser?” has not been separated in their head.
Sort that out and the right home for each piece falls into place naturally. Here is the table I paste into Claude Code every single time.
| Area | When to use | What may live here | Note for Claude Code |
|---|---|---|---|
| Server Component | Initial render, DB reads, SEO pages | DB access, auth checks, private API calls | Instruct it to put things here by default |
| Client Component | Input forms, modals, tabs, optimistic UI | useState, useActionState, click handlers | No secrets or DB clients |
| Server Action | Form submit, create, update, delete | Validation, permission checks, revalidation | Do not use it as a public API |
| Route Handler | External integration, webhook, mobile API | JSON response, status codes, signature checks | Always add input validation and auth |
Let me restate the terms in plain language. A Server Component is a piece that is rendered on the server and then sent over. A Client Component is a piece that runs in the browser. A Server Action is server code you call from a form or a button, and a Route Handler is the window that external clients hit over HTTP. The word BFF shows up too; think of it as “a thin backend built specifically for one screen.”
Handing this table over first makes Claude Code’s output noticeably more stable. In particular, do not drop the sentence “no process.env, DB, or auth secrets in Client Components.” My opening accident was the direct result of forgetting to say exactly that.
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
Specify the file layout up front
In App Router the filename becomes the URL. So if you leave placement vague, logic scatters between components and app, and later you start asking yourself “wait, which file did I write that update in?” I give Claude Code this shape first.
src/
app/
dashboard/
tasks/
page.tsx # list (Server Component)
new/
page.tsx # create screen
actions.ts # Server Action
api/
tasks/
route.ts # outward-facing API (Route Handler)
components/
task-create-form.tsx # input UI (Client Component)
lib/
auth.ts # auth (server-only)
env.ts # env var validation (server-only)
tasks.ts # data operations (server-only)
It looks small, but this shape reuses cleanly across a SaaS settings page, an internal request tool, a blog CMS, or a customer management dashboard. All it does is separate four things: “the screen,” “in-screen mutations,” “the outward-facing API,” and “server-only logic.”
Physically isolate server-only logic
Next is the data layer itself. Since this is a demo I store data in memory (an in-process array), but in production swap it for Prisma, Drizzle, Supabase, or whatever you use. The boundary thinking does not change when you swap.
The single most important thing here is line one: import "server-only";. With this in place, if a Client Component ever accidentally imports the module, the build fails and tells you. My opening accident would have been stopped before release if this had been there.
// 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;
}
Auth gets shut behind the same server-only wall. The version below is a quick one for local verification: if the browser cookie has demo_user_id, the user is treated as logged in, and that is all. In production swap it for Auth.js, Clerk, your own identity service, and so on. The design of auth itself (session vs JWT, how to store passwords) I split out into Locking down web auth with sessions, bcrypt, and cookies.
// 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();
}
Stop reading process.env directly all over your screens, too. Validate it in one place. This is where you enforce the rule of never importing DATABASE_URL or APP_SECRET from 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);
Build the initial render as a Server Component
The task list goes into a Server Component. DB reads, auth, and initial render are simpler and faster handled on the server first. Adding one line to Claude Code, “do not put use client on this file,” prevents needless client conversion.
// 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>
);
}
Notice that the function stays async function and calls await listTasks(...). Precisely because it is a Server Component, you can fetch data close to the database and render it directly. There is no need to go grab it with useEffect.
Funnel mutations into one Server Action
This is the heart of full-stack work. Form-driven mutations all collapse into a Server Action. Order matters: auth, then input validation, then save, then revalidate, top to bottom inside the same function.
Zod is a library that checks whether incoming data has the shape you expect. Telling Claude Code “always safeParse here” cuts down on dangerous implementations that flow straight to save without validation.
As the official docs warn rather firmly, a Server Action (Server Function) can be invoked by a direct POST without going through your app’s UI. So the assumption “only people who can press the button will call it” does not hold. That is why you must run requireUser() at the function’s entrance.
// 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> {
// 1. Auth first. Hiding it in the UI does not protect anything.
const user = await requireUser();
// 2. Validate input. Do not trust the request body.
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.",
};
}
// 3. Save.
await createTask({
ownerId: user.id,
...parsed.data,
});
// 4. Refresh the list cache so the latest data shows.
revalidatePath("/dashboard/tasks");
return {
ok: true,
message: "Task created.",
};
}
The Client Component holds only the state the browser truly needs. Do not import the DB, secret keys, or the server-only env here. For the submitting state, use useActionState and useFormStatus.
// 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>
);
}
The create page itself is a Server Component that just places this 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>
);
}
With that, the full loop runs: form submit, DB save, automatic list refresh. The key is that the single line <form action={formAction}> connects the browser form to the server-side handler.
Split outward-facing APIs into Route Handlers
“So should every mutation just be a Server Action?” No. An API hit by a mobile app, a third-party service, or a webhook belongs in a Route Handler. Server Actions are optimized for in-screen mutations, and when you press them into public-API duty, status codes and JSON-shaped errors tend to get fuzzy.
In a Route Handler you write input validation and auth explicitly, yourself. The code below returns 401 when not logged in, 400 for a bad priority, and 201 on success.
// 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 });
}
Note that request.json() is caught with .catch(() => null) before reaching safeParse. Even when broken JSON or an empty body arrives, you can return 400 without crashing. If you want to go deeper on building the REST API itself (routing, unified errors, pagination), pair this with Building a REST API in Node.
Four splits that pay off in real work
Here are concrete situations where this four-way split earns its keep.
The first is an internal request dashboard. The request list is a Server Component, request creation is a Server Action, and notifications to Slack and the like are a Route Handler. Split this way, screen responsibilities and external-integration responsibilities never blur together.
The second is a SaaS settings page. For operations like billing settings, team invitations, and API key issuance, put only the input UI in a Client Component and run the actual change through a Server Action after a permission check. The trick is to never equate “could press the button” with “has permission.”
The third is a blog CMS or product management. Render the initial list fast with a Server Component, and pull image uploads and public webhooks into Route Handlers. If adding or dropping DB columns is involved, reading the staged-rollout approach in Preventing accidents in DB migration operations first is the safe move.
The fourth is a BFF architecture. Instead of hitting multiple external APIs directly from the frontend, consolidate them into Next.js Route Handlers. This follows the thinking in the official Backend for Frontend guide verbatim, and keeps secret keys out of the browser.
Three pitfalls I fell into
Let me be honest. My first few attempts fell into roughly the same holes.
The first was the secret leak from the opening. Because server-first rendering is convenient, I kept piling logic into Client Components, and downstream a helper that read DATABASE_URL got dragged along to the browser side too. The fix is simple: add import "server-only"; to the top of the server-only file. From then on, the moment you try to create an accident, the build stops.
The second was fetching everything via useEffect even for the initial render. If you only ask Claude Code to “make a working list,” it sometimes writes code with use client that goes and fetches after mount. It works, but the first load is slow and it is weak for SEO. Once I specified up front “fetch the initial list render with await in a Server Component,” it corrected itself cleanly.
The third was believing I had protected auth with UI alone. I assumed “if I do not show the delete button to non-admins, nothing gets deleted,” but the official docs state plainly that a Server Action can be invoked by a direct POST. So even with the UI hidden, if you do not check requireUser() and the owner ID inside the function, it sails right through. A scary lesson.
The review prompt I hand to Claude Code
Once the implementation is done, do not let it jump straight to fixes. First, make it emit findings only. This reduced my accidents the most.
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.
Once findings appear, fix them one at a time, small. At minimum, verify with npm run lint, npm run typecheck, a manual form submission, and a check that the Route Handler returns 401 when not logged in. If you are unsure where to start writing tests, see Prioritizing your test strategy with Claude Code.
FAQ
Q. Server Action or Route Handler — which should I use?
For a form mutation inside a screen, Server Action. For something hit from the outside (mobile, third party, webhook), Route Handler. Even for the same save operation, deciding by whether the entrance is “my own screen” or “external” removes the doubt. Put save logic used by both in lib as a single function and call it from both sides.
Q. Where should I put use client?
Only on pieces that need state (useState), events like clicks, or browser-only APIs such as localStorage and window. Everything a file marked use client imports goes into the browser bundle together. So rather than “putting it on the whole page,” the trick is to narrow it to the small piece that needs interaction.
Q. How do I confirm secrets are not leaking to the browser?
First, put import "server-only"; at the top of server-only files. With that, importing them from a Client Component fails the build. On top of that, remember that the only env vars safe to ship to the browser are those prefixed with NEXT_PUBLIC_. Variables without the prefix come out empty on the client.
Q. I saved, but the list still shows old data.
Check that you are calling revalidatePath("/your-path") inside the Server Action. Saving alone does not refresh the cache. If you manage things by tag, use revalidateTag.
Q. What should I hand Claude Code first to keep it stable?
This article’s boundary table and file layout, plus two rules: the prohibition “no secrets, DB, or env in Client Components” and the principle “fetch the initial list render in a Server Component.” Handing those over first clearly reduces later rework.
What happened when I actually tried this
After the opening incident where a secret nearly leaked to the browser, I changed my approach. Before I let Claude Code build anything, I always paste the boundary table and the file layout. Server-only files get server-only added, no exceptions. That is it.
As a result, the content of my fix requests changed. Before, I had a lot of structural accidents like “the DB client ended up on the client side” or “the initial render is all useEffect.” Once I put the boundary into words up front, most of the rework shifted to small UI things like “button wording” or “spacing adjustments.” And since adding server-only, the build stops the instant I try to write a dangerous path, so I no longer get the late-night chills.
Conversely, on the runs where I was in a hurry and let the first instruction be “just make it look good,” sure enough form handling and API handling mixed, and the time spent reverting in review ballooned. The lesson is clear. In Next.js full-stack development, putting “where is the server, where is the browser” into words before you chase implementation speed is the fastest route of all.
If you want to standardize this across a team, ClaudeCodeLab has Claude Code training and templates and consultation for team adoption with a template that captures these boundary rules and review points. Use it when you want to tune it to your own repository conventions.
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.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
The Agency Permission Checklist Before Claude Code Edits a Client Site
A client-work permission checklist for safe AI-assisted edits on landing pages and websites.
Turn SaaS Support Bug Reports Into Repro Steps With Claude Code
A support-team workflow for converting vague tickets into safe, reproducible bug reports.
Turn Stale Obsidian Notes Into a Claude Code Brief in 10 Minutes
Obsidian notes that turn to mush when pasted? Sort them into facts, decisions, and unknowns so Claude Code can act on them right away.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.