ProgrUmar Logo
Module 2: React Server Components & Data Fetching

Server Actions & Forms

Duration: 22 mins

Server Actions

Mark an async function with "use server" and call it from a <form action> or a Client Component. The function runs entirely on the server — you can query your database directly.

Progressive Enhancement

Forms using Server Actions work even with JavaScript disabled, making them inherently accessible and resilient.

Server Actions & Forms

Every app needs to mutate data — create a post, update a profile, delete a comment, submit an order. In the Pages Router this meant writing an API route, fetching it from the client, managing loading and error state, and then refreshing the UI. That's a lot of boilerplate for something as fundamental as saving data.

Server Actions change everything. They let you write a server-side function and call it directly from a form or a Client Component — no API route, no manual fetch, no CORS setup. The function runs on the server, can talk to your database directly, and Next.js handles all the wiring automatically.


1. What Is a Server Action?

A Server Action is an async function marked with the "use server" directive. That directive tells Next.js: "This function always runs on the server, no matter where it's called from."

You can define Server Actions in two places:

Option A — Inline in a Server Component

// app/posts/new/page.tsx — Server Component
export default function NewPostPage() {
  // Defined inline inside a Server Component
  async function createPost(formData: FormData) {
    "use server"; // ← Makes this function a Server Action

    const title = formData.get("title") as string;
    const body  = formData.get("body") as string;

    await db.post.create({ data: { title, body } });
  }

  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" />
      <textarea name="body" placeholder="Post body" />
      <button type="submit">Publish</button>
    </form>
  );
}

Option B — In a Dedicated Actions File

// app/actions.ts
"use server"; // ← Top-level directive — every export in this file is a Server Action

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

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const body  = formData.get("body") as string;

  await db.post.create({ data: { title, body } });
  revalidatePath("/posts");
}

export async function deletePost(id: string) {
  await db.post.delete({ where: { id } });
  revalidatePath("/posts");
}

The dedicated file approach is cleaner for most projects — actions are reusable across multiple pages, easier to find, and easier to test.


2. How Forms Work with Server Actions

Pass a Server Action to the action prop of a <form> element. When the form is submitted, Next.js automatically collects all form field values into a FormData object and passes it to your Server Action:

// app/contact/page.tsx
import { sendContactEmail } from "@/app/actions";

export default function ContactPage() {
  return (
    <form action={sendContactEmail} className="space-y-4 max-w-md">
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" type="text" required />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
      </div>
      <div>
        <label htmlFor="message">Message</label>
        <textarea id="message" name="message" rows={5} required />
      </div>
      <button type="submit">Send Message</button>
    </form>
  );
}
// app/actions.ts
"use server";

export async function sendContactEmail(formData: FormData) {
  const name    = formData.get("name") as string;
  const email   = formData.get("email") as string;
  const message = formData.get("message") as string;

  // Send email, save to DB, whatever you need
  await sendEmail({ to: "admin@example.com", name, email, message });
}

Progressive Enhancement — Works Without JavaScript

This is one of the most underappreciated features of Server Actions. Because the form uses a native <form action>, it works as a standard HTML form submission even with JavaScript disabled. The Server Action runs on the server either way. You get full progressive enhancement for free.


3. Validation with Zod

Never trust form data without validation. The formData.get() returns string | File | null — you need to validate it before saving to your database. Zod is the standard choice:

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

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

const CreatePostSchema = z.object({
  title: z.string().min(3, "Title must be at least 3 characters").max(100),
  body:  z.string().min(10, "Body must be at least 10 characters"),
  slug:  z.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase with hyphens only"),
});

export async function createPost(formData: FormData) {
  // Parse and validate
  const result = CreatePostSchema.safeParse({
    title: formData.get("title"),
    body:  formData.get("body"),
    slug:  formData.get("slug"),
  });

  if (!result.success) {
    // Return validation errors to the client
    return {
      errors: result.error.flatten().fieldErrors,
    };
  }

  // Safe to use — Zod has validated and typed this
  const { title, body, slug } = result.data;

  await db.post.create({ data: { title, body, slug } });
  revalidatePath("/posts");

  return { success: true };
}

4. useActionState — Handling Responses and Errors

Server Actions can return values — but you need a way to receive them in the UI. React's useActionState hook (previously called useFormState) is the official solution. It wraps a Server Action and gives you the return value as state:

// app/posts/new/_components/CreatePostForm.tsx
"use client";

import { useActionState } from "react";
import { createPost } from "@/app/actions";

// Initial state before the form is submitted
const initialState = { errors: {}, success: false };

export default function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, initialState);

  return (
    <form action={formAction} className="space-y-4 max-w-md">

      <div>
        <label htmlFor="title">Title</label>
        <input id="title" name="title" type="text" />
        {state.errors?.title && (
          <p className="text-red-500 text-sm">{state.errors.title[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="slug">Slug</label>
        <input id="slug" name="slug" type="text" />
        {state.errors?.slug && (
          <p className="text-red-500 text-sm">{state.errors.slug[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="body">Body</label>
        <textarea id="body" name="body" rows={6} />
        {state.errors?.body && (
          <p className="text-red-500 text-sm">{state.errors.body[0]}</p>
        )}
      </div>

      {state.success && (
        <p className="text-green-600 font-medium">Post created successfully!</p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? "Publishing..." : "Publish Post"}
      </button>

    </form>
  );
}

useActionState returns three values:

  • state — the return value of the last Server Action call (or initialState before first submit)
  • formAction — the wrapped action to pass to the form's action prop
  • isPendingtrue while the Server Action is running

The Server Action signature needs one extra parameter when used with useActionState — the previous state:

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

import { z } from "zod";

type ActionState = {
  errors?: { title?: string[]; body?: string[]; slug?: string[] };
  success?: boolean;
};

export async function createPost(
  prevState: ActionState, // ← Required when used with useActionState
  formData: FormData
): Promise<ActionState> {
  const result = CreatePostSchema.safeParse({
    title: formData.get("title"),
    body:  formData.get("body"),
    slug:  formData.get("slug"),
  });

  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }

  await db.post.create({ data: result.data });
  revalidatePath("/posts");

  return { success: true };
}

5. Calling Server Actions from Client Components

Server Actions aren't limited to form submissions. You can call them directly from any event handler in a Client Component — click handlers, keyboard shortcuts, drag-and-drop, anything:

// components/LikeButton.tsx
"use client";

import { useState } from "react";
import { toggleLike } from "@/app/actions";

export default function LikeButton({
  postId,
  initialLikes,
}: {
  postId: string;
  initialLikes: number;
}) {
  const [likes, setLikes] = useState(initialLikes);
  const [liked, setLiked] = useState(false);
  const [isPending, setIsPending] = useState(false);

  async function handleLike() {
    setIsPending(true);

    // Optimistic update — update UI immediately
    setLikes((prev) => (liked ? prev - 1 : prev + 1));
    setLiked((prev) => !prev);

    // Then run the Server Action
    await toggleLike(postId);
    setIsPending(false);
  }

  return (
    <button
      onClick={handleLike}
      disabled={isPending}
      className={liked ? "text-red-500" : "text-gray-400"}
    >
      ♥ {likes}
    </button>
  );
}
// app/actions.ts
"use server";

export async function toggleLike(postId: string) {
  // Toggle like in database
  await db.like.upsert({
    where: { postId },
    create: { postId },
    update: {},
  });
}

This pattern — optimistic update first, Server Action second — gives users instant feedback while the server catches up in the background.


6. useOptimistic — First-class Optimistic Updates

React 19 ships useOptimistic — a hook specifically designed for optimistic UI updates with Server Actions. It's cleaner than managing optimistic state manually:

// components/TodoList.tsx
"use client";

import { useOptimistic } from "react";
import { addTodo } from "@/app/actions";

type Todo = { id: string; text: string; pending?: boolean };

export default function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos: Todo[], newTodo: Todo) => [...currentTodos, newTodo]
  );

  async function handleSubmit(formData: FormData) {
    const text = formData.get("text") as string;

    // Add optimistically — immediately visible, marked as pending
    addOptimisticTodo({ id: crypto.randomUUID(), text, pending: true });

    // Run the real Server Action
    await addTodo(text);
    // When complete, optimistic state is replaced with real data
  }

  return (
    <div>
      <ul>
        {optimisticTodos.map((todo) => (
          <li
            key={todo.id}
            className={todo.pending ? "opacity-50" : "opacity-100"}
          >
            {todo.text} {todo.pending && "(saving...)"}
          </li>
        ))}
      </ul>

      <form action={handleSubmit} className="mt-4 flex gap-2">
        <input name="text" placeholder="New todo..." className="border rounded px-2" />
        <button type="submit">Add</button>
      </form>
    </div>
  );
}

useOptimistic takes the real state and a reducer function. When you call addOptimisticTodo, it immediately applies the reducer to create the optimistic state. Once the Server Action completes and the parent re-renders with fresh data, the optimistic state automatically reverts to the real data.


7. Redirecting After a Server Action

A common pattern after a successful mutation is redirecting the user to a new page — for example, redirecting to the new post after creating it:

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

import { redirect } from "next/navigation";
import { db } from "@/lib/db";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const body  = formData.get("body") as string;
  const slug  = formData.get("slug") as string;

  const post = await db.post.create({
    data: { title, body, slug },
  });

  // Redirect to the new post — this throws internally, so no return needed
  redirect(`/blog/${post.slug}`);
}

Important: redirect() inside a Server Action must be called outside of a try/catch block — because it works by throwing a special error internally. If you catch all errors, you'll accidentally catch the redirect too:

// ❌ redirect() gets swallowed by the catch block
export async function createPost(formData: FormData) {
  try {
    await db.post.create({ ... });
    redirect("/posts"); // ← This throws, but catch swallows it
  } catch (error) {
    console.error(error); // redirect error gets logged here incorrectly
  }
}

// ✅ Correct — redirect outside of try/catch
export async function createPost(formData: FormData) {
  try {
    await db.post.create({ ... });
  } catch (error) {
    return { error: "Failed to create post" };
  }

  redirect("/posts"); // ← Called after try/catch, works correctly
}

8. Passing Extra Data to Server Actions

Sometimes you need to pass data to a Server Action that isn't in the form — like a record ID for an update or delete operation. Use .bind() to pre-fill arguments:

// app/posts/[id]/edit/page.tsx
import { updatePost } from "@/app/actions";

export default async function EditPostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const post = await db.post.findUnique({ where: { id } });

  // Bind the post ID as the first argument
  const updatePostWithId = updatePost.bind(null, id);

  return (
    <form action={updatePostWithId}>
      <input name="title" defaultValue={post?.title} />
      <textarea name="body" defaultValue={post?.body} />
      <button type="submit">Update Post</button>
    </form>
  );
}
// app/actions.ts
"use server";

export async function updatePost(id: string, formData: FormData) {
  // id comes from .bind(), formData comes from the form submission
  const title = formData.get("title") as string;
  const body  = formData.get("body") as string;

  await db.post.update({
    where: { id },
    data: { title, body },
  });

  revalidatePath(`/posts/${id}`);
  redirect(`/posts/${id}`);
}

You can also pass extra data via hidden form inputs — a simpler approach that doesn't require .bind():

<form action={updatePost}>
  <input type="hidden" name="id" value={post.id} />
  <input name="title" defaultValue={post.title} />
  <button type="submit">Update</button>
</form>

9. Security — Server Actions Are Public Endpoints

This is the most important thing to understand about Server Actions: they are HTTP endpoints. Next.js exposes them as POST requests that anyone can call — not just your own forms. This means you must treat them with the same security mindset as API routes.

Always Authenticate

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

import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

export async function deletePost(id: string) {
  // Always check authentication first
  const session = await auth();

  if (!session?.user) {
    redirect("/login");
  }

  // Check authorization — does this user own the post?
  const post = await db.post.findUnique({ where: { id } });

  if (post?.authorId !== session.user.id) {
    throw new Error("Unauthorized");
  }

  await db.post.delete({ where: { id } });
  revalidatePath("/posts");
}

Always Validate Input

Never trust formData values. Validate with Zod before using them — malicious users can send arbitrary POST requests to your Server Actions with any data they want.

Never Expose Sensitive Data in Return Values

Return values from Server Actions are sent to the client. Don't return database records with sensitive fields — shape the return value explicitly:

// ❌ Exposes sensitive fields
return await db.user.findUnique({ where: { id } });

// ✅ Only return what the client needs
const user = await db.user.findUnique({ where: { id } });
return { id: user.id, name: user.name, email: user.email };

10. Common Gotchas

  • Forgetting prevState when using useActionState. When a Server Action is used with useActionState, its first parameter must be the previous state. Forgetting this causes a TypeScript error and runtime mismatch.
  • Calling redirect() inside a try/catch. redirect() throws internally — if you catch all errors, you swallow the redirect. Always call redirect() after your try/catch block.
  • Not revalidating after mutations. After saving to the database, call revalidatePath() or revalidateTag() — otherwise the user sees stale cached data after the action completes.
  • Skipping authentication checks. Server Actions are HTTP endpoints. Malicious users can call them directly. Always verify the session at the top of every Server Action that reads or writes user data.
  • Using Server Actions in the wrong context. "use server" can only be used in async functions or at the top of a file. You can't mark a regular synchronous function or a class method as a Server Action.

Key Takeaways

  • Server Actions are async functions marked with "use server" — they always run on the server regardless of where they're called from.
  • Pass a Server Action to <form action={...}> — Next.js handles the FormData wiring automatically.
  • Forms using Server Actions work without JavaScript — full progressive enhancement out of the box.
  • Always validate with Zod before saving data — formData is user input and can't be trusted.
  • Use useActionState to receive validation errors and success responses back in the UI.
  • Call Server Actions directly from Client Component event handlers for non-form interactions.
  • Use useOptimistic for instant UI feedback before the server responds.
  • Call redirect() outside of try/catch blocks inside Server Actions.
  • Server Actions are public endpoints — always authenticate and validate inside every action.

That wraps up Module 2 — React Server Components & Data Fetching! You now have a complete picture of how data flows through a Next.js app — from fetching and caching to streaming and mutating. In Module 3 we switch gears to styling — CSS Modules, Tailwind CSS, fonts, and dark mode.

Chat with us