Module P-2·13 min read

How Server Actions are compiled, useActionState (the useFormState replacement), useOptimistic for instant UI, the after() API for post-response side effects, and Zod validation patterns.

Written by Jatin Jain Saraf · Architect of SupraScan

P-2 — Server Actions: Mutations Done Right

Who this is for: Practitioners who understand data fetching from P-1 and need to handle the other side of the equation — user-initiated mutations. Server Actions are how the App Router handles form submissions, data updates, and any write operation originating from the client. This module covers the full picture: how they actually work, the security implications most tutorials skip, and the patterns that make forms feel instant.


What Server Actions Actually Are

The name "Server Action" sounds like a special framework concept, but the mechanics are simpler than the name implies.

When you mark a function with 'use server', Next.js does three things at build time:

  1. Moves the function to the server bundle. The code never reaches the browser.
  2. Generates an endpoint. Next.js creates a unique POST endpoint for this function — the URL is a hash derived from the function's location, not a human-readable path.
  3. Creates a stub on the client. Instead of the function itself, the browser gets a small proxy that makes a POST request to that endpoint when called.

From the developer's perspective, you call a function. Under the hood, you're making an HTTP POST to an automatically generated endpoint. The function body executes on the server. The result is serialized and sent back to the client.

This is why Server Actions can only receive and return serializable values — they cross the network.


Defining Server Actions

Inline in a Server Component:

// app/posts/new/page.tsx (Server Component)
 
export default function NewPostPage() {
  async function createPost(formData: FormData) {
    'use server'; // ← makes this function a Server Action
 
    const title = formData.get('title') as string;
    const content = formData.get('content') as string;
 
    await db.posts.create({ data: { title, content } });
    revalidatePath('/blog');
    redirect('/blog');
  }
 
  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <button type="submit">Publish</button>
    </form>
  );
}

In a dedicated actions file:

// app/actions/posts.ts
'use server'; // ← file-level directive — all exports are Server Actions
 
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
 
const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  category: z.string(),
});
 
export async function createPost(formData: FormData) {
  const raw = {
    title: formData.get('title'),
    content: formData.get('content'),
    category: formData.get('category'),
  };
 
  const validated = CreatePostSchema.safeParse(raw);
 
  if (!validated.success) {
    return {
      error: validated.error.flatten().fieldErrors,
    };
  }
 
  const post = await db.posts.create({ data: validated.data });
 
  revalidateTag('posts');
  redirect(`/blog/${post.slug}`);
}

The file-level 'use server' directive is more maintainable for production codebases — all actions in one place, no risk of accidentally marking a utility function as a Server Action.


The Security Implications Nobody Talks About

Server Actions are HTTP endpoints. They're POST endpoints, but they're public — nothing stops someone from calling them directly with curl or Postman. There is no automatic authentication on Server Actions.

This is the most dangerous misconception about Server Actions: because they look like private functions, engineers sometimes assume they're protected by default.

Always authenticate inside Server Actions that touch sensitive data:

// app/actions/posts.ts
'use server';
 
import { auth } from '@/lib/auth';
 
// ❌ Don't assume the caller is authenticated
export async function deletePostUnsafe(postId: string) {
  await db.posts.delete({ where: { id: postId } });
}
 
// ✅ Always verify inside the action
export async function deletePost(postId: string) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');
 
  // ✅ Verify the user owns this post
  const post = await db.posts.findUnique({ where: { id: postId } });
  if (post?.authorId !== session.user.id) throw new Error('Forbidden');
 
  await db.posts.delete({ where: { id: postId } });
  revalidateTag('posts');
}

Validate all inputs. FormData.get() returns string | File | null. Treat it like untrusted user input from an external API, because that's exactly what it is:

// ❌ Trusting the input shape
export async function updatePrice(formData: FormData) {
  const price = formData.get('price') as number; // price is a string, not a number
  await db.products.update({ where: { id }, data: { price } }); // type error at runtime
}
 
// ✅ Validate and parse
export async function updatePrice(formData: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');
 
  const result = z.object({
    productId: z.string().cuid(),
    price: z.coerce.number().positive().max(99999.99),
  }).safeParse({
    productId: formData.get('productId'),
    price: formData.get('price'),
  });
 
  if (!result.success) return { error: result.error.flatten() };
 
  await db.products.update({
    where: { id: result.data.productId, ownerId: session.user.id },
    data: { price: result.data.price },
  });
 
  revalidateTag(`product-${result.data.productId}`);
}

z.coerce.number() converts the string "29.99" from FormData into the number 29.99. Always coerce and validate, never cast.


useActionState — Form State Management

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.