Next.js & the Network
Next.js & the Network

Server Components, Server Actions & Route Handlers: where code runs

Full · ~40 min
After this unit you can
  • 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.ts redirect that swallows a Server Action and the catch that swallows redirect()/notFound(), and fix each

Read

Simple
1 / 2

Who calls this code? Four kinds of file in the App Router

Flag

In 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:

KindMarked byRunsCalled by
Server Componentnothing: the default for page.tsx, layout.tsx and what they importonly on the server, per request or ahead of time at buildNext.js, while it renders a route
Client Component'use client' at the top of the fileon the server to prerender HTML, then in the browserReact in the browser (hydration, events)
Server Action'use server'on the serverthe browser, over the network
Route Handlerapp/**/route.ts exporting GET, POST…on the serveranyone 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.

Medium
1 / 2

Editing a post — page, form and action, line by line

Flag

A 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 }}, not post={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 getPostForEditing decided what to render. The action is a separate entry point: anyone can POST any id to it, so without the authorId check 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.
  • revalidatePath before redirect. redirect() throws, so nothing after it runs. In a Server Action the action's POST answers 303 See Other, the status meant for "POST done, now GET this". Before hydration (a native form post) it carries Location and the browser follows it; after hydration it carries an x-action-redirect header instead, and the client router navigates (Next 16.2).

Complex
1 / 2

A Server Action is a POST to the page that renders it

Flag

When 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?

Flag

page.tsx (a Server Component) renders <Picker onPick={(id) => console.log(id)} />, and Picker's file starts with 'use client'. What happens?

Flag

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?

Flag

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?

Flag

Stripe must notify your app when a payment succeeds. What receives the notification?

Flag

app/products/page.tsx does await fetch("https://shop.example/api/products"), its own Route Handler, which reads the database. What's wrong?

Flag

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?

Flag

You edit the proxy matcher so it no longer runs for /settings/:path*, to speed those pages up. What else stopped going through proxy?

Flag

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.

Flag
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.

Flag
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'
Flag
"use server";
export async function publish(formData: FormData) {
  try {
    await savePost(formData); // succeeds
    redirect("/posts");
  } catch {
    return { error: "Could not publish." };
  }
}
Flag
// 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 row
Flag

Challenge

Make the auth proxy redirect page visits only

Flag

proxy.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:

  1. Server Action calls. The sign-up form creates the account in the browser, which sets the session cookie, then calls the createProfile() Server Action. That call is a POST to /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.
  2. Static files. The login page's own CSS and JavaScript (/_next/static/…) and public files like /logo.png are redirected to /login for 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 /login or /signup redirects to /
  • HEAD is treated like GET
  • 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

Loading the in-browser editor…

Further reading