Server Actions in the App Router

WebNext.jsReactFull Stack
Share on LinkedIn

You added a "Save profile" form and reached for fetch('/api/profile', { method: 'PATCH' }) out of habit. In the App Router, Server Actions let you call an async function on the server directly from a form or event handler—no API route boilerplate, no client-side fetch wrapper, and the form works before hydration completes. The mental model shift is treating server mutations like regular functions with "use server" at the top of the file.

Defining a Server Action

// app/actions/profile.ts
"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";

const ProfileSchema = z.object({
  name: z.string().min(1).max(100),
  bio: z.string().max(500).optional(),
});

export async function updateProfile(formData: FormData) {
  const session = await auth();
  if (!session?.user?.id) throw new Error("Unauthorized");

  const parsed = ProfileSchema.safeParse({
    name: formData.get("name"),
    bio: formData.get("bio"),
  });
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }

  await db.user.update({
    where: { id: session.user.id },
    data: parsed.data,
  });

  revalidatePath("/settings");
  return { success: true };
}

The "use server" directive marks exported functions as callable from the client via a secure POST channel.

Form integration

// app/settings/page.tsx
import { updateProfile } from "@/app/actions/profile";

export default function SettingsPage() {
  return (
    <form action={updateProfile}>
      <input name="name" required />
      <textarea name="bio" />
      <button type="submit">Save</button>
    </form>
  );
}

No onSubmit, no preventDefault. The browser POSTs to Next.js, which invokes your function.

Client-side pending states

"use client";

import { useFormStatus, useFormState } from "react-dom";
import { updateProfile } from "@/app/actions/profile";

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Saving…" : "Save"}
    </button>
  );
}

export function ProfileForm() {
  const [state, action] = useFormState(updateProfile, null);

  return (
    <form action={action}>
      <input name="name" />
      {state?.error?.name && <p>{state.error.name}</p>}
      <SubmitButton />
    </form>
  );
}

useFormStatus must be called from a child of the form—wrapping the button in its own component is required.

Revalidation after mutations

import { revalidatePath, revalidateTag } from "next/cache";

// Invalidate a specific page
revalidatePath("/posts");

// Invalidate all pages using a fetch cache tag
revalidateTag("posts");

// Invalidate dynamic segment
revalidatePath("/posts/[slug]", "page");

Call revalidation inside the Server Action after the database write succeeds. Stale cache is worse than a slow response.

Calling actions from event handlers

"use client";

import { deletePost } from "@/app/actions/posts";

export function DeleteButton({ postId }: { postId: string }) {
  return (
    <button
      onClick={async () => {
        await deletePost(postId);
      }}
    >
      Delete
    </button>
  );
}

Server Actions accept serializable arguments—strings, numbers, plain objects, FormData, and arrays thereof. Not functions, class instances, or DOM nodes.

Security checklist

Server Actions vs Route Handlers

Concern Server Action Route Handler
Form POST Native Manual parsing
External API consumers No Yes
Custom HTTP status codes Limited Full control
File uploads FormData works Raw body access
Caching headers Not applicable Full control

Use both: Server Actions for in-app mutations, Route Handlers for webhooks and public APIs.

Error handling

export async function createPost(formData: FormData) {
  try {
    // ... validation and insert
    return { success: true, id: post.id };
  } catch (e) {
    console.error("createPost failed", e);
    return { error: "Failed to create post. Try again." };
  }
}

Uncaught exceptions return a generic error to the client. Return structured errors for expected failures (validation, conflicts).

Optimistic updates with useOptimistic

React 19's useOptimistic pairs with Server Actions for instant UI feedback:

"use client";
import { useOptimistic } from "react";
import { toggleTodo } from "./actions";

export function TodoList({ todos }) {
  const [optimistic, addOptimistic] = useOptimistic(todos, (state, id) =>
    state.map(t => t.id === id ? { ...t, done: !t.done } : t)
  );

  return optimistic.map(todo => (
    <form key={todo.id} action={async () => {
      addOptimistic(todo.id);
      await toggleTodo(todo.id);
    }}>
      <button>{todo.done ? "✓" : "○"} {todo.text}</button>
    </form>
  ));
}

Optimistic state reverts automatically if the action throws — handle errors in the action return value for user-visible rollback messages.

revalidatePath vs revalidateTag

Cache invalidation after mutations:

"use server";
import { revalidatePath, revalidateTag } from "next/cache";

export async function updateProduct(id: string, data: FormData) {
  await db.product.update({ where: { id }, data: ... });
  revalidateTag(`product-${id}`);      // granular
  revalidatePath("/products");          // page-level
}

Tag-based revalidation scales better — one product update doesn't invalidate the entire site. Fetch with { next: { tags: [product-${id}] } }.

Pair with Next.js metadata SEO API when server actions update content that affects page metadata.

Common production mistakes

Teams get app router server actions wrong in predictable ways:

Production implementations of app router server actions fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.

Resources

Frequently asked questions

When should I use Server Actions instead of API routes?

Use Server Actions for form submissions and mutations triggered from React components in the same Next.js app. Use API routes when external clients, webhooks, or non-React consumers need the endpoint, or when you need fine-grained HTTP method and header control.

Do Server Actions work without JavaScript?

Yes. Server Actions are HTML form actions under the hood. With the form action attribute pointing to a server function, the form submits via POST even when JS fails to load. useFormStatus and client-side optimistic updates require JS.

How do I prevent CSRF attacks on Server Actions?

Next.js validates the Origin header and sets encrypted action IDs automatically in production. Do not disable these checks. For additional protection, include a CSRF token in forms handling sensitive operations.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →