Server actions (Next.js)

Status: 🟩 COMPLETE (🟦 LIVING — the pattern is evolving as Next.js iterates) Last updated: 2026-06-19 Plain-English tagline: A way to call a function defined on the server directly from a React component — no API route, no fetch, no JSON serialization to write by hand — and have the typing flow end-to-end.


In plain English

For the entire history of webapps until ~2023, the way the browser asked the server to do something was always:

  1. Write a backend endpoint (POST /api/posts)
  2. Define the request shape (JSON body)
  3. Define the response shape (JSON body)
  4. Write client code that calls fetch('/api/posts', { method: 'POST', body: JSON.stringify(...) })
  5. Manually serialize / deserialize / handle errors / track loading state

Even with TypeScript, the link between the frontend fetch and the backend handler was loose — you wrote types on both sides and hoped they matched.

Server actions (introduced in Next.js 13.4, stable in 14+) collapse all that into a single primitive:

// app/actions.ts
"use server";
 
export async function createPost(formData: FormData) {
  const title = formData.get("title")?.toString();
  await db.posts.create({ data: { title } });
}
// app/new-post/page.tsx
import { createPost } from "../actions";
 
export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button type="submit">Save</button>
    </form>
  );
}

That’s it. No /api/posts/route.ts. No fetch. No JSON. Submitting the form calls createPost on the server. The result of the action flows back automatically. Types reach across the wire without a single line of glue code.

The "use server" directive at the top of the file is the magic word: it tells Next.js “the functions in this file ONLY run on the server, but client components can call them as if they were local.” Behind the scenes, Next.js compiles client-side code into a network call to the server.

In effect: typed RPC, hidden as a function call. This is the same pattern as tRPC, Telefunc, and Convex, but built into Next.js itself.


Why it matters

Three concrete reasons server actions are a big deal:

  1. End-to-end type safety with zero glue code. TypeScript types declared in the action’s signature are the contract. Refactor the action; the call site has a compile error. No drift between client and server.

  2. No more API design overhead for first-party flows. Most webapps have dozens of “create thing, edit thing, delete thing” actions that are only ever called by their own frontend. Server actions skip the URL design, the verb choice, the status-code argument, the JSON shape. Write a function; use it.

  3. Progressive enhancement. A <form action={...}> with a server action works WITHOUT JavaScript. The form submits via plain HTML form post, the server handles it, the page reloads. Then on the client, React enhances it to be smooth. This makes the app more accessible and resilient than a pure-JS approach.

The trade-off: server actions are tied to Next.js (and React Server Components). If you ever migrate off Next.js, every server action call site is rework. For first-party flows within a long-term Next.js project, the simplification is well worth it.


How server actions actually work under the hood

Conceptually:

1. You write a "use server" function
                ↓
2. Next.js's compiler sees the directive and generates an ID for that function
                ↓
3. Anywhere the function is called from a client component, Next.js
   replaces the call site with: "POST /__next/action with this ID + args"
                ↓
4. On the server, Next.js's router receives the POST, looks up the
   function by ID, deserializes the args, calls the real function,
   and returns the result serialized to the client
                ↓
5. The client receives the result as the resolved value of the await

It IS a fetch under the hood — just one Next.js writes for you. You see only the function call.

The mechanism that makes this safe:

  • The action IDs are unguessable hashes generated at build time
  • Inputs are validated/deserialized server-side
  • The action runs in the same auth context as a normal page request (cookies, session, etc. all available)

Three ways to invoke a server action

1. As a form action attribute (progressive enhancement)

<form action={createPost}>
  <input name="title" />
  <button type="submit">Save</button>
</form>

Submitting the form invokes the action with a FormData object. Works without JavaScript: the form HTML-POSTs to a URL Next.js generated; the page reloads with the result.

2. As an explicit call in a client event handler

"use client";
import { deletePost } from "../actions";
 
export function DeleteButton({ id }: { id: string }) {
  return (
    <button onClick={async () => {
      await deletePost(id);
    }}>
      Delete
    </button>
  );
}

Same action, different invocation. Works only with JavaScript (no progressive enhancement here).

3. Wrapped with React’s useTransition or useActionState for loading state

"use client";
import { useActionState } from "react";
import { createPost } from "../actions";
 
export function NewPostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null);
 
  return (
    <form action={formAction}>
      <input name="title" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Saving…" : "Save"}
      </button>
      {state?.error && <p>{state.error}</p>}
    </form>
  );
}

This binds the action to React’s transition system. The component gets isPending (true while the action is running) and the action’s return value as state. Server-rendered error states work without JS; the client enhances with loading UI when JS is available.


A concrete example: a small form with validation

// app/actions.ts
"use server";
 
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
 
const CreatePostSchema = z.object({
  title: z.string().min(1, "Title is required").max(200),
  body: z.string().min(1, "Body is required"),
});
 
export async function createPost(prevState: any, formData: FormData) {
  const user = await getCurrentUser();
  if (!user) return { error: "Not authenticated" };
 
  const parsed = CreatePostSchema.safeParse({
    title: formData.get("title"),
    body: formData.get("body"),
  });
 
  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message };
  }
 
  const post = await db.posts.create({
    data: { ...parsed.data, authorId: user.id },
  });
 
  revalidatePath("/posts");
  redirect(`/posts/${post.id}`);
}

Notice:

  • "use server" at the top → server-only
  • getCurrentUser() reads the session cookie — auth context flows automatically
  • z.parse validates input
  • revalidatePath tells Next.js to refresh the cached /posts page so the new post shows
  • redirect navigates the client to the new post’s page

The client-side form code can be ~10 lines and never imports anything Node-only. Yet the round trip is fully typed.


Revalidation — keeping cached pages fresh

A subtle but powerful integration: server actions can invalidate Next.js’s cache.

import { revalidatePath, revalidateTag } from "next/cache";
 
await db.posts.create({ ... });
 
// Refresh a specific page
revalidatePath("/posts");
 
// Or refresh by tag (set when fetching data)
revalidateTag("posts");

This is necessary because Next.js heavily caches pages (in production). After mutating data, the cached page is stale until you invalidate it.

The pattern: action mutates → action revalidates → client sees fresh data automatically.


Server actions vs API routes vs tRPC

Three patterns for “client talks to server” in a Next.js project:

PatternBest for
Server actionsFirst-party forms, mutations, anything where the call site IS your frontend
API routes (/app/api/.../route.ts)Third-party callers (mobile apps, webhooks, public APIs); endpoints that need explicit URLs
tRPCHeavy multi-package codebases that want shared typed clients across many apps

For a typical solo Next.js project in 2026: server actions for almost everything; API routes only where you need a real URL (webhooks, third-party integrations, OAuth callbacks).


Common patterns

Authentication context

The server action runs with access to cookies and headers — same context as a page request. Reading the session is straightforward:

"use server";
import { cookies } from "next/headers";
 
export async function someAction() {
  const session = cookies().get("session-token");
  if (!session) throw new Error("Unauthenticated");
  // ...
}

Returning data to the form

For form actions with useActionState, return a structured state:

"use server";
 
export async function createPost(prevState: any, formData: FormData) {
  // validation...
  if (!parsed.success) return { error: parsed.error.message };
  // success path
  return { success: true, post };
}

The client component sees this as the new state after the action completes.

Redirecting after an action

redirect() from next/navigation works inside an action — but it throws an internal exception that Next.js catches. Don’t wrap it in try/catch.

import { redirect } from "next/navigation";
 
export async function createPost(formData: FormData) {
  const post = await db.posts.create({ ... });
  redirect(`/posts/${post.id}`);  // Don't catch this
}

Optimistic UI

useOptimistic from React lets you show predicted state instantly, then reconcile:

"use client";
import { useOptimistic } from "react";
 
const [optimisticPosts, addOptimisticPost] = useOptimistic(posts, (state, newPost) => [...state, newPost]);
 
async function handleAdd(formData: FormData) {
  addOptimisticPost({ title: formData.get("title"), id: "tmp" });
  await createPost(formData);
}

The UI updates instantly with the predicted result; when the action returns, React reconciles with the real data.


Security model

Server actions are callable from any client that knows the URL Next.js generated. The URL itself is hard to guess (it includes the action ID hash), but it’s not secret — anyone who’s loaded your page can see it in their network tab and call it again.

This means:

  • Every server action MUST validate auth and inputs. Treat it like a public endpoint.
  • Never trust client-supplied user IDs. Read the user from the session cookie, not from a form field.
  • Validate all inputs with Zod (or similar). TypeScript types on the function signature are NOT enforced at runtime — they’re just hints.
  • Be aware of CSRF. Next.js applies some protections (same-origin enforcement), but defense-in-depth (CSRF tokens, double-submit cookies) is wise for sensitive operations.

A naive export async function deletePost(id: string) that doesn’t check whether the caller OWNS the post is a major security hole. The function signature gives no protection.


Common gotchas

  • "use server" is a runtime contract, not just a comment. Forget it, and Next.js may include your server code in the client bundle — leaking secrets. Add the directive at the top of every action file.

  • Server actions can leak server-only imports. Importing a server-only library (database, file system) into a CLIENT component triggers a build error. But importing INTO an action file in a way that gets bundled with client code can subtly leak. Use the server-only package to enforce.

  • TypeScript types don’t enforce runtime. A signature like (id: string) doesn’t mean the caller can only pass a string. Validate explicitly.

  • FormData values are strings (or files). A name="age" input gives you formData.get("age") as a string "42". Coerce with Zod or Number().

  • Cookies must be set before any output. In a server action that needs to set a cookie, use cookies().set(...) early. Setting cookies after redirect() doesn’t work.

  • redirect() throws an exception. Don’t try/catch it — Next.js needs the exception to propagate. If you must wrap in try/catch, rethrow isRedirectError results.

  • Actions are POST-only. A bot crawler hitting your page won’t accidentally invoke an action. But an action triggered via a form submission with no JS leaves the user on a page that may or may not reload.

  • Closures over component state don’t survive serialization. A server action that captures a client component’s local state through a closure doesn’t see runtime values. Pass values as arguments instead.

  • Revalidation has costs. revalidatePath('/') invalidates the home page cache; the next request rebuilds it. Do it deliberately, not after every action.

  • revalidatePath is server-only. Don’t expect it to magically update the current page — the client gets the revalidated version on its NEXT request.

  • Errors in server actions don’t always surface gracefully. Without useActionState, an unhandled throw shows up as a generic error overlay in dev and an obscure error in production. Wrap actions in try/catch where possible.

  • Server actions create new function URLs on each build. A bookmark or stale tab might call an URL that no longer exists. Handle the resulting 404 gracefully.

  • You can’t pass complex objects directly — they’re serialized. Date, Map, Set, class instances, functions — all become plain objects (or lose data). Stick to JSON-compatible types.

  • File uploads work but with quirks. A <input type="file" /> with a form action gets a File object in FormData. Streaming large files needs explicit handling.

  • Multi-step forms across actions can be tricky. Each action runs in isolation — no shared in-memory state. Persist intermediate state in cookies, the URL, or the database.

  • Testing server actions requires Next.js runtime. Unit-testing them in isolation is awkward. Either test the underlying business logic (extracted into utility functions) or use end-to-end testing (Playwright) for the action.

  • Browser back button after an action can be confusing. A form submitted via action, followed by a redirect, makes the back button take the user back to the FORM — but the form is now stale. Plan UX accordingly.

  • Server actions on slow connections feel slower than fetch. They include the full Next.js routing layer, not just a hot path. For very latency-critical operations, an explicit API route may be marginally faster.

  • Calling a server action from another server action is just a function call. No round trip. Useful for composing actions.

  • Streaming responses from server actions is limited. Long-running streams (LLM responses) work better via API routes with ReadableStream.

  • Don’t use server actions for what’s actually a public API. If a third-party developer wants to call your endpoint, expose a stable URL (REST or GraphQL), not a Next.js action with a build-generated ID.

  • "use server" directives are file-level OR function-level. A whole file marked "use server" makes ALL exports actions. A single function "use server" inside a module makes only that function an action. Be deliberate.

  • The Next.js error overlay obscures real errors in development. Look at the server console; the browser overlay is often a downstream symptom.


When server actions are right — and when they aren’t

Right:

  • First-party form submissions in a Next.js app
  • Mutations triggered from your own React components
  • Anything where typed function calls are a better abstraction than typed JSON
  • Progressive-enhancement-friendly flows

Wrong (or awkward):

  • Public APIs consumed by third-party developers
  • Webhooks from external services (they need a stable URL)
  • Mobile apps consuming your backend
  • Anywhere you’ll migrate the frontend off Next.js
  • Streaming responses (LLM-style)
  • Long-running operations beyond serverless time limits

For Bible Quest-style projects (Next.js webapp where ALL callers are the Bible Quest frontend), server actions are the right default for mutations.


See also


Sources