Frontend · Next.js
Server Actions & Data Fetching in Next.js
How Next.js collapses the client-server round trip for mutations and reads alike — and the cases where you still want a real API route.
Ask a traditional single-page app to save a form, and a predictable chain of plumbing kicks in: define an API route, fetch it from the client, serialize the body, wait for a response, and juggle loading and error state the whole time. None of that is hard on its own, but it's boilerplate you write again for every single mutation. Next.js's App Router collapses a lot of this by letting server code and client markup sit in the same file, called as if it were one ordinary function call.
The traditional round trip
In the classic model, a form's submit handler calls fetch("/api/todos", { method: "POST" }), that request hits an API route you defined separately, the route talks to the database, and the response travels back to the client so it can update its own state. It works, and it scales to very complex systems, but every mutation pays the same tax: an endpoint to define, a client-side call to wire up, and a small state machine — idle, loading, success, error — to track by hand. Multiply that by every form in a medium-sized app and it adds up to a lot of nearly identical code.
Server Actions: a function that happens to run on the server
A Server Action is a function marked with the "use server" directive, which tells the framework "this code only ever executes on the server, no matter who calls it." Once marked, that function can be passed directly to a form's action prop, or invoked from a Client Component's event handler, and Next.js handles the network call underneath — serializing the arguments, sending the request, and returning the result — without you writing a fetch call or a route handler at all.
The example below defines a Server Action that inserts a row and wires it straight to a form's action attribute. No separate /api/todos route exists anywhere in this app; the form submission is the network call.
// app/todos/actions.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function createTodo(formData: FormData) {
const title = formData.get("title") as string;
await db.todo.create({ data: { title, done: false } });
// Tell Next.js the cached /todos page no longer reflects reality
revalidatePath("/todos");
}
// app/todos/page.tsx
import { createTodo } from "./actions";
export default function TodosPage() {
return (
<form action={createTodo}>
<input name="title" placeholder="New todo" />
<button type="submit">Add</button>
</form>
);
}Notice there's no onSubmit, no fetch, and no manual JSON parsing. The browser submits the form the way it always has — a native, progressively-enhanced POST — and Next.js routes that submission to the marked server function. For a large class of mutations, this quietly removes the need to hand-write an API endpoint at all.
Reading data without a client-side round trip
The same collapse happens on the read side, for a different reason. A Server Component renders on the server before any HTML reaches the browser, which means it can await a database query or a fetch call directly in the component body:
const todos = await db.todo.findMany(); return <TodoList items={todos} />;
Compare that to a client-side data-fetching hook, which has to render once with nothing, kick off a request after mount, track loading and error states, and re-render when the data arrives — an entire extra client-server round trip just to display the page's initial content. A Server Component skips that dance entirely: the data is already baked into the HTML the browser receives on the very first response.
Not every fetch has to move to the client
A common instinct coming from a traditional SPA is to reach for a data-fetching hook by default. In the App Router, if a component doesn't need interactivity, it doesn't need to be a Client Component at all — and if it isn't a Client Component, it can just await its data and skip the loading state entirely.
When you still want a real API route
Server Actions are not a full replacement for API routes, and reaching for one everywhere is its own mistake. Two situations still call for an explicit endpoint — or at least a Server Action invoked outside of a form:
- Non-form mutations. A Server Action bound to a form's
actionattribute gets progressive enhancement for free, but a "like" button or a drag-and-drop reorder triggered from a plainonClickhandler is calling the action directly as a function — which works, but you lose some of the built-in form semantics and need to think about pending and error states yourself, much like a regular fetch call. - Callers outside your Next.js app. A Server Action is only reachable through the Next.js app that defines it. If the same logic needs to be called from a mobile client, a third-party webhook, or another service entirely, it belongs behind a real API route with its own stable contract — a Server Action isn't addressable from outside the app's own React tree.
A mutation doesn't refresh the UI by itself
Inserting a row doesn't automatically tell Next.js that a page which previously rendered the old data is now stale. That's why the example above calls revalidatePath after the insert — without it, a cached route could keep showing the pre-mutation state even though the database already changed. Next.js's caching model is deep enough to deserve its own treatment, but the short version is: every Server Action that mutates data needs to say, explicitly, what it just made stale.
Key takeaways
- The traditional model hand-wires a client fetch to a separate API route for every mutation; Server Actions let a form call a server function directly.
- A function marked with "use server" can be passed to a form's action prop or called from a Client Component, with Next.js handling the network call underneath.
- Server Components can await data directly in the component body, eliminating the client-side loading/error state machinery a data-fetching hook needs.
- Reach for a real API route (or a non-form Server Action call) when a mutation comes from a click handler rather than a form, or when the logic must be reachable from outside the Next.js app.
- A Server Action that mutates data still has to explicitly revalidate the cached routes that depended on it, or the UI can keep showing stale state.
