Next.js75 min total · 12 parts
Next.js Fundamentals: The App Router, Server Components, and the Caching Rules That Just Flipped
Part 7 of 12 · ~5 min
Server Actions
Tag any async function "use server" and it becomes a Server Action — something a Client Component can invoke like a normal function, or that a plain <form> can trigger on its own, with neither a Route Handler nor a hand-written fetch call needed to bridge the two. Here's how a candidate applies to a job on OpenRoles:
// app/jobs/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function applyToJob(formData: FormData) {
const jobId = formData.get("jobId") as string;
const email = formData.get("email") as string;
await db.application.create({ data: { jobId, email } });
revalidateTag(`job-${jobId}`, "hours"); // that job's applicant count just changed
}
// app/jobs/[slug]/ApplyForm.tsx
import { applyToJob } from "./actions";
export default function ApplyForm({ jobId }: { jobId: string }) {
return (
<form action={applyToJob}>
<input type="hidden" name="jobId" value={jobId} />
<input name="email" type="email" placeholder="you@example.com" required />
<button type="submit">Apply</button>
</form>
);
}
Picture what a mutation like this used to require: a Route Handler to receive the submission, a client-side fetch to actually reach it, pending and error state hand-managed around that call, and two separate function signatures — one on the server, one on the client — that someone has to remember to update together every time either changes. A Server Action collapses that whole chain down into one function, callable directly or, as above, handed straight to a form's action, with Next.js taking care of the actual network plumbing underneath.
Choosing a Server Action versus a Route Handler
Both can handle a simple mutation, and it's tempting to treat them as interchangeable — but they exist for different callers.
| Server Action | Route Handler | |
|---|---|---|
| Feels like | An ordinary function call, from a form or from your own client code | An HTTP endpoint — you reach it with fetch("/api/...") |
| Reachable by | Only components inside this same app | Anything on the internet that can send a request — a partner server, a webhook, a mobile client |
| What it is, underneath | An async function with a special directive | A handler with full say over status codes, headers, and response shape |
| Survives JavaScript failing | Yes — a plain <form action={...}> still submits | No — it needs a fetch call that actually runs to be reached at all |
Why it still works with JavaScript switched off
Because applyToJob sits directly in the form's action, that apply button keeps working even when the browser's JavaScript never finishes loading, errors out halfway, or is switched off entirely — the form degrades to whatever browsers have always done with a plain submit, a full HTTP round trip, and the Server Action still executes on the receiving end of it. That's not a hypothetical for OpenRoles: a candidate applying from a shaky mobile signal mid-commute, on a page whose bundle stalls out, still gets their application through. Wrap the form in a Client Component using useFormStatus and you can layer an instant "Submitting…" state on top of that — a nicety added to something that was already correct without it, rather than the only thing keeping the form alive.
The security trap this pattern hides
However friendly the syntax looks, a Server Action becomes a real network endpoint with its own URL the moment it's compiled — whether or not you ever intend to call it that way. deleteJobPosting(jobId) reads like an ordinary function call from inside a component, but Next.js has quietly stood up a real, reachable endpoint underneath it — pull apart the client bundle and that endpoint is sitting right there for anyone curious enough to look, ready to be called with a plain HTTP request that never goes near your button, your component, or any check you wrote in either one.
"use server";
// WRONG — assumes the only caller could ever be the employer who owns this
// listing, because that's the only place this was meant to be triggered from
export async function deleteJobPosting(jobId: string) {
await db.job.delete({ where: { id: jobId } });
}
// RIGHT — re-verify ownership inside the function itself, every single
// call, because the action is reachable as a public endpoint regardless
export async function deleteJobPosting(jobId: string) {
const session = await getSession();
const job = await db.job.findUnique({ where: { id: jobId } });
if (!session?.employerId || job?.employerId !== session.employerId) {
throw new Error("Unauthorized");
}
await db.job.delete({ where: { id: jobId } });
}
Skip that check and any signed-in employer — not just the one who posted it — could remove a competitor's listing by locating the action's generated endpoint and passing along someone else's jobId. Hiding the delete button from everyone but the owning employer stops nobody determined; that's a convenience for people using the UI honestly, not a security boundary against anyone who isn't.
One layer of protection here comes free, though it's a supplement, not a substitute: Next.js checks the Origin header on a Server Action request against the app's own host and rejects a mismatch outright — a built-in guard against some other site quietly triggering your action from an embedded page. That closes off one category of abuse entirely. It does nothing for a legitimately signed-in employer who's deliberately targeting a competitor's jobId, which is exactly the gap the ownership check inside the function body has to cover on its own.