Skip to content
C Codeloom
Next.js

Next.js Server Actions Deep Dive

Server Actions explained: what they are under the hood, how to use them for forms and mutations, and the security and UX patterns that matter.

·5 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • What Server Actions really are under the hood
  • How to wire them to forms with progressive enhancement
  • How to validate and authorize on the server
  • Optimistic UI patterns
  • Common security gotchas

Prerequisites

  • Next.js App Router basics

Server Actions are async functions you can call from a client component as if they were local, but they execute on the server. The framework handles the RPC. They are not a magic wand: they are a thin layer with rules about validation, authorization, and revalidation. Treat them like API endpoints with a nicer syntax, because that is what they are.

What they are

A function with 'use server' is registered as a server endpoint. When you import it into a client component and call it, the framework serializes the arguments, makes a POST to the server, runs the function, and gives you back the result. Because the function is a real endpoint, the same security rules apply as for any API.

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { z } from 'zod';

const Input = z.object({ title: z.string().min(1).max(120) });

export async function createPost(formData) {
  const parsed = Input.parse({ title: formData.get('title') });
  await db.posts.insert(parsed);
  revalidatePath('/posts');
}

Mental model

Client component
 |
 | onSubmit / startTransition
 v
React serializes args -> POST /__next/action
 |
 v
Server runs the function -> revalidate / return value
 |
 v
Client gets result + new RSC payload
Server Action request flow

The “magic” is that React knows how to encode arguments and decode return values, including across React Server Component re-renders. Everything else is normal HTTP.

Hands-on: a form with progressive enhancement

A form bound to a Server Action works without JavaScript. The browser POSTs the form, the server runs the action, and the page is re-rendered. With JS, the same action runs without a full page reload.

// app/posts/new/page.tsx
import { createPost } from '../actions';

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <button type="submit">Create</button>
    </form>
  );
}

For loading states and errors, use the useActionState and useFormStatus hooks.

'use client';
import { useActionState } from 'react';
import { createPost } from '../actions';

export default function NewPostForm() {
  const [state, action, pending] = useActionState(createPost, { error: null });
  return (
    <form action={action}>
      <input name="title" required />
      {state.error && <p>{state.error}</p>}
      <button disabled={pending}>Create</button>
    </form>
  );
}

The action now returns { error } on failure, and pending flips while the request is in flight.

Hands-on: optimistic updates

useOptimistic lets you show the change immediately while the action runs.

'use client';
import { useOptimistic } from 'react';
import { togglePost } from '../actions';

export function PostRow({ post }) {
  const [optimistic, applyOptimistic] = useOptimistic(post, (s, next) => ({ ...s, ...next }));
  return (
    <form action={async (fd) => {
      applyOptimistic({ done: !optimistic.done });
      await togglePost(fd);
    }}>
      <input type="hidden" name="id" value={post.id} />
      <button>{optimistic.done ? 'Undo' : 'Done'}</button>
    </form>
  );
}

If the action throws, React reverts the optimistic state automatically.

Security: actions are public endpoints

Anyone who can POST to your site can invoke an action. The fact that you only call it from a logged-in component is irrelevant; the endpoint is public. Always re-check auth and authorization inside the action.

'use server';
import { getSession } from '@/lib/auth';

export async function deletePost(id) {
  const session = await getSession();
  if (!session) throw new Error('unauthorized');
  const post = await db.posts.find(id);
  if (post.userId !== session.userId) throw new Error('forbidden');
  await db.posts.delete(id);
}

Validate inputs with Zod or similar. The client can send anything.

Common pitfalls

  • Trusting the caller. Actions are routes. Re-authenticate and re-authorize every time.
  • Returning non-serializable values. Dates, Maps, and Sets are fine; class instances, functions, and Buffers are not.
  • Forgetting to revalidate. Mutations that do not call revalidatePath or revalidateTag leave stale cached pages.
  • Putting actions in client files. 'use server' at the top of a client component is not what you want; the directive declares a server function and the file must not also be a client component.
  • Catching errors silently. Thrown errors are how React knows to roll back optimistic state.
  • Sending large payloads. Actions go through the same body-size limits as other endpoints; uploads belong in route handlers with streaming.

Practical tips

  • Co-locate actions with the route that uses them. app/posts/actions.ts next to app/posts/page.tsx.
  • Always validate with a schema and parse before you use formData.
  • Return discriminated unions ({ ok: true, data } | { ok: false, error }) instead of throwing for expected business errors; reserve throws for unexpected failures.
  • Use useFormStatus inside the submit button for accurate disabled state.
  • Rate-limit actions like any API. Per-IP, per-user, per-action.

Wrap-up

Server Actions remove a lot of glue between forms and mutations: no fetch, no manual JSON, no route handler boilerplate, and progressive enhancement for free. The price is remembering that they are real public endpoints. Validate, authorize, revalidate, and you get the ergonomics without the foot-guns.