React Server Actions: A Complete Guide With Real Examples

Before Server Actions, the standard pattern for any mutation in a Next.js app — form submission, data update, record deletion — required an API route. You'd write /api/contact, point your form's action or fetch call at it, handle the request, validate the body, write to the database, and return a response. This worked, but it meant maintaining a parallel layer of route handlers alongside your components — two files per mutation instead of one.

Server Actions collapse this into a single function. You define a function marked with 'use server', call it directly from your component, and Next.js handles the HTTP transport behind the scenes. The mental model is closer to RPC (Remote Procedure Call) than REST: you're calling a named function, not hitting an endpoint.

This post covers how Server Actions work mechanically, patterns for common use cases, and where the security responsibilities lie.

How Server Actions Actually Work

The 'use server' directive at the top of a function (or a file) tells Next.js's compiler to treat that function as a Server Action. During build, Next.js assigns each action a unique ID and creates a corresponding server endpoint. When a Client Component calls the action, Next.js's runtime makes a POST request to that endpoint, executes the function on the server, and returns the result to the client.

From the developer's perspective, it looks like calling a local async function. Underneath, it's an HTTP request. This abstraction is powerful but also the source of Server Actions' most common misunderstanding — they're not magically private just because they look like local function calls.

There are two ways to define Server Actions:

// Option 1: Inline in a Server Component
// app/contact/page.tsx

export default function ContactPage() {
  async function submitContactForm(formData: FormData) {
    'use server'; // This function becomes a Server Action
    const name = formData.get('name') as string;
    const email = formData.get('email') as string;
    // Write to database, send email, etc.
    await saveContactSubmission({ name, email });
  }

  return (
    <form action={submitContactForm}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit">Send</button>
    </form>
  );
}

// Option 2: Separate file for reuse across multiple components
// app/actions/contact.ts
'use server'; // Marks entire file as Server Actions

export async function submitContactForm(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  await saveContactSubmission({ name, email });
}

export async function updateUserProfile(formData: FormData) {
  // Another action in the same file
}

The separate file approach is preferable for anything used in more than one place. It also makes it easy to apply the server-only package import guard to prevent these functions from accidentally being called client-side in test environments.

Progressive Enhancement: Forms Without JavaScript

One underappreciated property of Server Actions used with native HTML <form> elements: they work without JavaScript enabled in the browser. When a form's action attribute is set to a Server Action function, the browser's native form submission mechanism handles it. JavaScript failing to load, being blocked, or running slowly doesn't prevent form submission.

This is meaningful for contact forms, checkout flows, and sign-up forms where you want guaranteed submission capability regardless of client conditions. It's not meaningful for highly interactive forms that depend on JavaScript for field dependencies, conditional rendering, or instant validation — those require JavaScript by definition.

// This form works without JavaScript
export default function NewsletterForm() {
  async function subscribe(formData: FormData) {
    'use server';
    await addEmailToList(formData.get('email') as string);
    redirect('/subscribed'); // Works with or without JS
  }

  return (
    <form action={subscribe}>
      <input name="email" type="email" placeholder="your@email.com" required />
      <button type="submit">Subscribe</button>
    </form>
  );
}

useFormState and useFormStatus for Feedback

Real forms need to communicate to users: is the submission in progress? Did it succeed? Did something fail? React 19 (shipped with Next.js 15) provides useActionState (formerly useFormState in earlier versions) for this. Alongside it, useFormStatus gives you the pending state for disabling submit buttons.

// app/actions/profile.ts
'use server';
import { z } from 'zod';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const ProfileSchema = z.object({
  displayName: z.string().min(2).max(50),
  bio: z.string().max(500).optional(),
});

export async function updateProfile(prevState: unknown, formData: FormData) {
  const session = await auth();
  if (!session?.user) return { error: 'Unauthorised' };

  const parsed = ProfileSchema.safeParse({
    displayName: formData.get('displayName'),
    bio: formData.get('bio'),
  });

  if (!parsed.success) {
    return { error: parsed.error.errors[0].message };
  }

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

  return { success: true };
}
// ProfileForm.tsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { updateProfile } from '@/app/actions/profile';

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

export default function ProfileForm({ currentName, currentBio }) {
  const [state, formAction] = useActionState(updateProfile, null);

  return (
    <form action={formAction}>
      {state?.error && <p className="error">{state.error}</p>}
      {state?.success && <p className="success">Profile updated.</p>}
      <input name="displayName" defaultValue={currentName} />
      <textarea name="bio" defaultValue={currentBio} />
      <SubmitButton />
    </form>
  );
}

The useFormStatus hook must live in a child component of the form — it reads the pending state from the nearest parent form context. Placing it in the same component as useActionState won't work as expected, hence the separate SubmitButton component.

Cache Invalidation After Mutations

When a Server Action modifies data, the relevant cached pages and components need to reflect the change. Next.js provides two functions for this:

  • revalidatePath(path) — invalidates the cache for a specific URL path, causing the next request to re-fetch server data
  • revalidateTag(tag) — invalidates all cached requests that were tagged with a specific cache tag, across all paths
// app/actions/cart.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function addToCart(productId: string, quantity: number) {
  const session = await auth();
  if (!session?.user) throw new Error('Must be logged in');

  await db.cartItem.upsert({
    where: { userId_productId: { userId: session.user.id, productId } },
    update: { quantity: { increment: quantity } },
    create: { userId: session.user.id, productId, quantity },
  });

  revalidatePath('/cart');          // Refreshes the /cart page
  revalidateTag('cart-count');      // Refreshes any component tagged 'cart-count'
}

revalidateTag pairs with tagged fetch calls in Server Components:

// In a Server Component — tagged cache entry
const cartCount = await fetch('/api/cart/count', {
  next: { tags: ['cart-count'] }
}).then(r => r.json());

When revalidateTag('cart-count') runs inside a Server Action, every cached response tagged with 'cart-count' across all routes is invalidated. The next request that needs that data re-fetches it. This is more precise than revalidatePath when the same data appears on multiple pages.

Security: Server Actions Are Public HTTP Endpoints

This bears repeating because it's the most consequential thing to understand about Server Actions. Every Server Action in your codebase is a callable HTTP POST endpoint. The URL is obfuscated (it's a hashed action ID, not /api/update-user), but it's not secret — anyone who loads your pages can extract the action IDs from the HTML and call them with any payload.

A secure Server Action validates, authenticates, and authorises every call:

// app/actions/admin.ts
'use server';
import { z } from 'zod';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const DeletePostSchema = z.object({
  postId: z.string().uuid(),
});

export async function deletePost(formData: FormData) {
  // Step 1: Authenticate — is the user logged in?
  const session = await auth();
  if (!session?.user) {
    return { error: 'Authentication required' };
  }

  // Step 2: Validate — is the input well-formed?
  const parsed = DeletePostSchema.safeParse({
    postId: formData.get('postId'),
  });
  if (!parsed.success) {
    return { error: 'Invalid request' };
  }

  // Step 3: Authorise — does this user own this resource?
  const post = await db.post.findUnique({
    where: { id: parsed.data.postId },
    select: { authorId: true },
  });
  if (!post || post.authorId !== session.user.id) {
    return { error: 'Not authorised' }; // Don't reveal whether the post exists
  }

  // Step 4: Execute — safe to proceed
  await db.post.delete({ where: { id: parsed.data.postId } });
  revalidatePath('/blog');
  return { success: true };
}

Notice that the authorisation check returns the same error message whether the post doesn't exist or the user doesn't own it. This prevents an attacker from using error messages to probe which post IDs exist in the database.

When API Routes Are Still the Better Choice

Server Actions are not a universal replacement for API routes. Several scenarios call for a traditional API route instead:

  • External consumers: Mobile apps, third-party integrations, or other services calling your backend need documented, stable endpoints — not Next.js Server Action IDs that may change between deployments.
  • Webhooks: Stripe, GitHub, and similar services POST to URLs you provide. They can't call Server Actions.
  • Streaming responses: Server Actions do not support streaming return values. If you need to stream a response (LLM output, file downloads, server-sent events), use an API route with a ReadableStream.
  • Non-Next.js clients: If a Server Action's functionality needs to be called by a React Native app or a non-Next.js frontend, an API route is the correct abstraction.
  • File uploads larger than form multipart limits: Server Actions handle FormData with files, but for large file uploads (video, archives), a dedicated upload endpoint with streaming support and size limit control is more appropriate.

The practical rule: if the action is only ever called from your Next.js frontend, Server Actions reduce boilerplate with no downside. If anything external needs to call the same logic, expose it through an API route and optionally call that route from a Server Action.

Frequently Asked Questions

Are Server Actions secure? Can malicious users call them directly?

Server Actions are public HTTP endpoints, not private server functions. Next.js exposes them as POST endpoints that any HTTP client can call — not just your frontend. This means you must treat them exactly like API routes for security purposes. Every Server Action should validate its input (use Zod to reject malformed data), verify authentication (check the session or JWT before any database write), and authorise the operation (confirm the authenticated user has permission to modify the specific resource, not just that they're logged in). A common mistake is assuming the 'use server' directive provides access control — it does not. It only tells Next.js where to run the function. The security boundary is entirely your responsibility to implement inside the action body.

Can I use Server Actions with client-side validation libraries like Zod?

Yes, and it's the recommended pattern. Use Zod on the server inside the action for authoritative validation — this runs regardless of what the client sends. Optionally, share the same Zod schema with the client for immediate user feedback without a round-trip. The schema lives in a shared file imported by both the Server Action and the client-side form component. On the server, call schema.safeParse(formData) and return validation errors as part of the action's return value. On the client, useActionState receives these errors and displays them inline. This gives you fast client-side feedback and guaranteed server-side validation from a single schema definition.

Do Server Actions work with form libraries like React Hook Form?

With caveats. React Hook Form manages form state entirely on the client and uses its own handleSubmit to intercept submissions. Server Actions use the native HTML form action attribute and work best with useActionState and useFormStatus, which are designed for the progressive enhancement model. The two can coexist: use React Hook Form for complex validation and conditional fields, then call the Server Action manually inside React Hook Form's handleSubmit callback as an async function rather than a form action prop. The trade-off is losing progressive enhancement — the form requires JavaScript. For forms that genuinely benefit from React Hook Form's field array, watch, and complex validation features, this trade-off is usually worth it. For simpler forms, the native useActionState approach is cleaner.