Server Components, Server Actions & Route Handlers: where code runs
- Explain what runs where in the App Router: Server Components only on the server (async, no state or effects), and
'use client'as the entry to the client module graph whose props must be serialisable - Choose a Server Component read, a Server Action or a Route Handler for a job, from who sends the request and whether it reads or writes
- Explain that a Server Action is a POST to the page that renders it, reachable without your UI, and write the session and ownership check inside it
- Spot the
proxy.tsredirect that swallows a Server Action and thecatchthat swallowsredirect()/notFound(), and fix each
Read
Who calls this code? Four kinds of file in the App Router
FlagIn the Next.js 16 App Router, every file you write is one of four kinds, and the kind decides where it runs and who can call it:
| Kind | Marked by | Runs | Called by |
|---|---|---|---|
| Server Component | nothing: the default for page.tsx, layout.tsx and what they import | only on the server, per request or ahead of time at build | Next.js, while it renders a route |
| Client Component | 'use client' at the top of the file | on the server to prerender HTML, then in the browser | React in the browser (hydration, events) |
| Server Action | 'use server' | on the server | the browser, over the network |
| Route Handler | app/**/route.ts exporting GET, POST… | on the server | anyone who has the URL |
One page uses three of them:
// app/posts/[id]/page.tsx — Server Component: async, reads the database directly
import { getPost } from "@/data/posts";
import { LikeButton } from "./like-button";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const post = await getPost(id); // the query runs on the server and never ships to the browser
return (
<article>
<h1>{post.title}</h1>
<LikeButton postId={id} initialLikes={post.likes} />
</article>
);
}
// app/posts/[id]/like-button.tsx — Client Component: state and a click handler
"use client";
import { useState } from "react";
import { likePost } from "./actions";
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
return <button onClick={async () => setLikes(await likePost(postId))}>Like ({likes})</button>;
}
// app/posts/[id]/actions.ts — Server Action: the click becomes a network request
"use server";
export async function likePost(postId: string): Promise<number> {
// check who is asking, write the like, return the new count
}
The split follows from what each environment has. A Server Component runs once to produce
output: there is no "later" on the server, so no useState, no useEffect, no onClick. In
exchange it can be async, await a query, and read secrets from process.env. Anything that
needs to react to the user lives behind 'use client'.
params is a Promise: since Next 15 route params are async, and Next 16 removed the old
synchronous access.
Editing a post — page, form and action, line by line
FlagA realistic edit screen in Next.js 16: a Server Component loads the post, a Client Component renders the form, and a Server Action saves it.
// app/posts/[id]/edit/page.tsx
import { notFound } from "next/navigation";
import { getPostForEditing } from "@/data/posts"; // server-only: checks session + ownership
import { EditForm } from "./edit-form";
import { updatePost } from "./actions";
export default async function EditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const post = await getPostForEditing(id); // null when missing or not yours
if (!post) notFound();
return <EditForm post={{ id: post.id, title: post.title }} action={updatePost} />;
}
// app/posts/[id]/edit/edit-form.tsx
"use client";
import { useActionState } from "react";
type State = { error?: string };
type Props = {
post: { id: string; title: string };
action: (prev: State, data: FormData) => Promise<State>;
};
export function EditForm({ post, action }: Props) {
const [state, formAction, pending] = useActionState(action, {});
return (
<form action={formAction}>
<input type="hidden" name="id" value={post.id} />
<input name="title" defaultValue={post.title} />
<button disabled={pending}>{pending ? "Saving…" : "Save"}</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
// app/posts/[id]/edit/actions.ts
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function updatePost(_prev: { error?: string }, formData: FormData) {
const session = await auth();
if (!session) return { error: "Please sign in again." };
const id = String(formData.get("id"));
const title = String(formData.get("title") ?? "").trim();
if (title.length < 1 || title.length > 120) return { error: "Title must be 1–120 characters." };
const post = await db.post.findUnique({ where: { id } });
if (!post || post.authorId !== session.user.id) return { error: "Not allowed." };
await db.post.update({ where: { id }, data: { title } });
revalidatePath(`/posts/${id}`);
redirect(`/posts/${id}`);
}
Why each piece is the way it is:
post={{ id, title }}, notpost={post}. Props are serialised into the page; the whole row would put every column in the browser. Pass what the form renders.action={updatePost}. A Server Function is the one kind of function allowed to cross the client boundary as a prop.useActionState(action, {})returns[state, formAction, pending], and the action is called as(previousState, formData). Returning{ error }puts the message on screen without throwing.- The action checks the session and the owner again. The page's check in
getPostForEditingdecided what to render. The action is a separate entry point: anyone can POST anyidto it, so without theauthorIdcheck this is an IDOR hole. String(formData.get(...))and the length check. Form values arrive as strings typed by the client. Validate before they reach the database.revalidatePathbeforeredirect.redirect()throws, so nothing after it runs. In a Server Action the action'sPOSTanswers303 See Other, the status meant for "POST done, now GET this". Before hydration (a native form post) it carriesLocationand the browser follows it; after hydration it carries anx-action-redirectheader instead, and the client router navigates (Next 16.2).
A Server Action is a POST to the page that renders it
FlagWhen the browser calls a Server Action, Next's client sends POST to the current page's URL
with a Next-Action header naming the action. There is no /api/actions/updatePost route. Three
production consequences follow from that one fact.
1. It is reachable without your UI. Anyone can replay that POST with different arguments. Next makes action IDs hard to guess (encrypted, regenerated between builds) and drops unused actions from the build, and the docs still say: treat every action as a public endpoint.
export default async function AdminPage() {
const session = await auth();
if (!session?.user.isAdmin) redirect("/login"); // decides what to RENDER
async function deleteAll() {
"use server";
// ❌ no check here: the page's redirect can't stop a direct call
await db.record.deleteMany();
}
return <form action={deleteAll}><button>Delete all</button></form>;
}
The page-level redirect gates the HTML. The action is a separate entry point, so it needs its
own auth() check, and an ownership check whenever it touches one record (post.authorId === session.user.id), or any signed-in user can edit anyone's data by changing an id.
2. proxy.ts runs for it — and can eat it. Proxy (Next 16's name for middleware.ts) sees
the action as a POST to the page's path. A redirect written for page visits answers the action
too:
// proxy.ts — "signed-in users don't need the sign-up page"
if (pathname === "/signup" && request.cookies.has("session")) {
return NextResponse.redirect(new URL("/", request.url)); // also hits POST /signup
}
If the browser-side sign-up sets the session cookie and the page then calls createProfile(),
that POST to /signup now carries the cookie, gets a 307, and the action never runs. The page
looks fine; the profile row is simply never written. The fix is to redirect only page visits:
const isPageVisit = request.method === "GET" || request.method === "HEAD";
if (isPageVisit && pathname === "/signup" && request.cookies.has("session")) { … }
The reverse also holds: the docs warn that a matcher which excludes a path also skips every action on that path, so a refactor can silently remove proxy coverage. Another reason the auth check belongs inside the action.
3. The origin is checked. Next compares the request's Origin with Host (or
X-Forwarded-Host) and aborts a mismatch. Behind a reverse proxy with a different public host,
list it in experimental.serverActions.allowedOrigins. (CSRF itself is the Web Security track's
subject.)
Quiz
A layout renders a logo, six nav links and one search box that uses useState. Where does 'use client' go?
page.tsx (a Server Component) renders <Picker onPick={(id) => console.log(id)} />, and Picker's file starts with 'use client'. What happens?
page.tsx renders <Modal><Cart /></Modal>. Modal is a Client Component; Cart is a Server Component that queries the database. Does Cart's query code reach the browser?
The Data Security guide's admin page redirects non-admins, and its inline delete action checks isAdmin again. Why isn't the page's check enough?
Stripe must notify your app when a payment succeeds. What receives the notification?
Flagapp/products/page.tsx does await fetch("https://shop.example/api/products"), its own Route Handler, which reads the database. What's wrong?
A search box calls a Server Action on every keystroke to fetch results, and results arrive late and in a burst. What's the underlying problem?
FlagYou edit the proxy matcher so it no longer runs for /settings/:path*, to speed those pages up. What else stopped going through proxy?
Fill in the blank
This component holds state. Make it the entry point of a Client Component.
Flag
import { useState } from "react";
export function SearchBox() {
const [q, setQ] = useState("");
return <input value={q} onChange={(e) => setQ(e.target.value)} />;
}Mark every export of this file as a Server Action that a Client Component may import.
Flag
import { auth } from "@/lib/auth";
export async function archivePost(postId: string) {
const session = await auth();
if (!session) throw new Error("Unauthorized");
// load the post, check session.user.id owns it, then archive
}Receive a payment provider's webhook. Read the body exactly as sent, since signature checks run over the raw bytes.
Flag// app/api/webhooks/payments/route.ts export async function (request: Request) { const payload = await request.(); // verify the signature header against payload, then handle the event return new Response(null, { status: 200 }); }
Only redirect page visits in proxy.ts, so Server Action calls to the same path pass through.
export function proxy(request: NextRequest) { const isPageVisit = ["GET", "HEAD"].includes(request.); if (isPageVisit && request.nextUrl.pathname === "/signup" && request.cookies.has("session")) { return NextResponse.redirect(new URL("/", request.url)); } }
This loader may call notFound(). Let Next's signals through before logging real errors.
import { notFound, unstable_rethrow } from "next/navigation"; try { const post = await loadPost(id); // calls notFound() when missing return <Post post={post} />; } catch (err) { (err); console.error("post failed to load", err); return <p>Couldn't load this post.</p>; }
Predict the output
// app/orders/[id]/page.tsx — Server Component
class Money { constructor(public cents: number, public currency: string) {} }
<OrderCard
placedAt={new Date("2026-10-01")}
total={new Money(4200, "USD")}
tags={new Set(["gift"])}
onCancel={() => cancelOrder(order.id)}
/>
// order-card.tsx starts with 'use client'"use server";
export async function publish(formData: FormData) {
try {
await savePost(formData); // succeeds
redirect("/posts");
} catch {
return { error: "Could not publish." };
}
}// proxy.ts
export function proxy(request: NextRequest) {
const signedIn = request.cookies.has("session");
if (request.nextUrl.pathname === "/signup" && signedIn) {
return NextResponse.redirect(new URL("/", request.url));
}
}
// app/signup/signup-form.tsx ('use client'), on submit:
await signUpInBrowser(email, password); // sets the "session" cookie
await createProfile(formData); // a Server Action that inserts the profile rowChallenge
Make the auth proxy redirect page visits only
Flagproxy.ts is the auth gate for an internal tool: every page needs a session cookie except
/login and /signup. Signed-out visitors go to /login?next=…; signed-in visitors who open
/login or /signup go to /.
It has no matcher, so Next runs it for every request, not only page visits. Two kinds of
request slip into it that it was never written for:
- Server Action calls. The sign-up form creates the account in the browser, which sets the
sessioncookie, then calls thecreateProfile()Server Action. That call is aPOSTto/signup, the page that renders the form, and it now carries the cookie. The proxy answers it with a 307 and the profile row is never written. Nothing on the server errors. - Static files. The login page's own CSS and JavaScript (
/_next/static/…) and public files like/logo.pngare redirected to/loginfor a signed-out visitor.
fake-next-server.ts mirrors the shape of NextRequest / NextResponse so the function reads
like a real proxy.ts.
Your task
Edit proxy.ts so the tests pass:
- A signed-out visit to a private page redirects (307) to
/login?next=<path and query> - A signed-in visit to
/loginor/signupredirects to/ HEADis treated likeGET- A
POST(a Server Action call) is never redirected, signed in or not - Files under
/_next/and paths ending in a file name with an extension, like/logo.png, are never redirected
Further reading
- Next.js docs: Server and Client Components
- Next.js docs: Mutating Data (Server Functions)
- Next.js docs: route.js (Route Handlers)
- Next.js docs: Backend for Frontend (caveats)
- Next.js docs: Data Security
- Next.js docs: proxy.js
- Next.js docs: redirect
- Next.js docs: unstable_rethrow
- React docs: 'use client' (serializable types)
- React docs: 'use server' (security considerations)