Skip to content

React Server Actions: Patterns, Pitfalls, and Production Use

Server Actions bring form handling and mutations back to the server in Next.js — how to use them well, what to avoid and which patterns actually scale.

4 min read
Next.js Server Actions code handling form mutations with TypeScript types

Server Actions shipped into React's stable model with a lot of promise: colocate mutation logic with your components, skip the boilerplate API route, get progressive enhancement for free. In practice, teams are hitting real problems — validation gaps, mixed client/server state, and actions that grow into unmaintainable blobs. Here's a grounded look at where they shine and where you need guardrails.

What Server Actions Actually Are

A Server Action is an async function marked with "use server" that executes on the server but can be called from the client — including from form action props. The browser sends a POST request under the hood; React handles serialization.

tstypescript
"use server";
 
export async function createProject(formData: FormData) {
  const name = formData.get("name") as string;
  await db.project.create({ data: { name } });
  revalidatePath("/projects");
}

The simplicity is genuine. But that simplicity can lull you into skipping the patterns that keep server code safe.

Validation Is Your Responsibility

The biggest pitfall: Server Actions don't validate input automatically. Anything from FormData arrives as string | File | null. If you skip validation, you're accepting arbitrary user input into your database.

tstypescript
// ❌ Trusting FormData directly — no schema, no guardrails
export async function createProject(formData: FormData) {
  const name = formData.get("name") as string;
  await db.project.create({ data: { name } }); // name could be empty or malicious
}
 
// ✅ Parse and validate with Zod before touching the database
import { z } from "zod";
 
const schema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().max(500).optional(),
});
 
export async function createProject(formData: FormData) {
  const parsed = schema.safeParse({
    name: formData.get("name"),
    description: formData.get("description"),
  });
 
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }
 
  await db.project.create({ data: parsed.data });
  revalidatePath("/projects");
  return { success: true };
}

Treat a Server Action like a public API endpoint — because it is one.

Authorization: Don't Rely on the UI

Server Actions are not protected by being "inside" a component. A client can POST to any server action endpoint directly. Always check authorization inside the action itself.

tstypescript
"use server";
 
import { getSession } from "@/lib/auth";
 
export async function deleteProject(projectId: string) {
  const session = await getSession();
  if (!session?.user) {
    throw new Error("Unauthorized");
  }
 
  // Verify the user actually owns this project
  const project = await db.project.findUnique({ where: { id: projectId } });
  if (project?.ownerId !== session.user.id) {
    throw new Error("Forbidden");
  }
 
  await db.project.delete({ where: { id: projectId } });
  revalidatePath("/projects");
}

This is the same rule as for API routes. The component tree is not a security boundary.

Return Types for Client Feedback

Server Actions can return values. Use typed returns to give the client feedback — validation errors, success state, or IDs of created resources.

tstypescript
type ActionResult<T = void> =
  | { success: true; data: T }
  | { success: false; error: string; fieldErrors?: Record<string, string[]> };
 
export async function createProject(
  formData: FormData
): Promise<ActionResult<{ id: string }>> {
  const parsed = schema.safeParse(Object.fromEntries(formData));
 
  if (!parsed.success) {
    return {
      success: false,
      error: "Validation failed",
      fieldErrors: parsed.error.flatten().fieldErrors,
    };
  }
 
  const project = await db.project.create({ data: parsed.data });
  return { success: true, data: { id: project.id } };
}

On the client, use useActionState (React 19) or useFormState (Next.js 14) to bind the return value to component state.

tstypescript
"use client";
 
import { useActionState } from "react";
import { createProject } from "./actions";
 
export function CreateProjectForm() {
  const [state, action, isPending] = useActionState(createProject, null);
 
  return (
    <form action={action}>
      <input name="name" required />
      {state?.fieldErrors?.name && (
        <span role="alert">{state.fieldErrors.name[0]}</span>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create Project"}
      </button>
    </form>
  );
}

Organizing Actions at Scale

The instinct is to colocate — put the action in the same file as the component. That's fine for simple cases. As soon as an action touches authorization, complex business logic, or is shared across features, pull it into a dedicated module.

PatternWhen to use
Colocated in page.tsx or form.tsxSimple forms, single-use mutations
Feature module features/projects/actions.tsActions shared within a domain
Service layer + thin actionComplex business logic, reused across contexts

The service layer pattern is especially useful when the same logic needs to run in both a Server Action and an API route:

tstypescript
// services/projects.ts — pure business logic, no "use server"
export async function createProjectForUser(
  userId: string,
  data: CreateProjectInput
) {
  // validate, authorize, write to DB — framework-agnostic
}
 
// features/projects/actions.ts — thin action, just auth + delegate
"use server";
 
export async function createProjectAction(formData: FormData) {
  const session = await getSession();
  if (!session?.user) throw new Error("Unauthorized");
 
  const result = await createProjectForUser(
    session.user.id,
    parseFormData(formData)
  );
  revalidatePath("/projects");
  return result;
}

When business logic lives in a plain service function, you can unit test it without mounting a Next.js server or mocking React internals.

Error Handling: Throw Carefully

Unhandled throws from Server Actions surface as unhandled errors to the nearest error boundary. This is intentional for unexpected failures — database timeouts, network errors, invariant violations. But for expected failures, return a typed error value instead of throwing.

tstypescript
// ❌ Throws for an expected condition — triggers error boundary unnecessarily
export async function getProject(id: string) {
  const project = await db.project.findUnique({ where: { id } });
  if (!project) throw new Error("Not found");
  return project;
}
 
// ✅ Return null for expected absence; throw only for unexpected failures
export async function getProject(id: string) {
  return db.project.findUnique({ where: { id } });
}

The distinction matters because error boundaries are a blunt instrument. A "not found" doesn't need to blow up the entire subtree — the component can handle null gracefully.

Progressive Enhancement

One genuine win Server Actions deliver: forms work without JavaScript. Because the action prop accepts a function that maps to an HTTP POST, your forms work in environments where JS hasn't loaded yet or has failed.

tstypescript
// This form submits correctly with and without JavaScript
<form action={createProject}>
  <input name="name" required />
  <button type="submit">Create</button>
</form>

Don't throw this away by wrapping your action in onClick. Use the action prop on the <form> element, and add progressive enhancement on top — optimistic updates, pending states — as a layer, not a replacement.

~

Use useOptimistic alongside useActionState to update UI immediately while the action runs in the background. Roll back automatically if the action returns an error.

Key Takeaways

  1. Validate every input with a schema — FormData is untyped user input; treat it like any public API payload
  2. Authorize inside the action — the component tree is not a security boundary; check session and ownership explicitly
  3. Return typed results for expected failures — validation errors and missing records shouldn't trigger error boundaries
  4. Keep business logic in a service layer — thin actions delegate to reusable functions, making logic testable without framework overhead
  5. Preserve progressive enhancement — use the action prop on <form>, not onClick, so forms work before JS loads
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX