DEV Community

Cover image for Next.js Server Actions: Complete Guide with Examples for 2026
Md. Maruf Rahman
Md. Maruf Rahman

Posted on • Originally published at marufrahman.live

Next.js Server Actions: Complete Guide with Examples for 2026

Server Actions are one of those features that completely changed how I think about form handling in Next.js. Instead of creating API routes and managing fetch calls, you can write server-side functions that work seamlessly with your forms. The best part? They work even when JavaScript is disabledβ€”progressive enhancement built right in.

When I first started using Next.js, handling forms meant creating API routes, writing fetch calls, managing loading states, and dealing with error handling. It worked, but it felt like a lot of boilerplate for something that should be simple. Then Server Actions came along, and everything clicked.

πŸ“– Want the complete guide with more examples and advanced patterns? Check out the full article on my blog for an in-depth tutorial with additional code examples, troubleshooting tips, and real-world use cases.

What Are Server Actions?

Server Actions are async functions that run on the server. They're marked with the 'use server' directive and can be called directly from client or server components. When used with forms, they provide progressive enhancementβ€”your forms work even if JavaScript fails to load.

Here's the simplest exampleβ€”a Server Action that creates a post:

"use server";

import { createPost } from "./lib/posts-store";

export async function createPostAction(title) {
  const post = createPost(title);
  return post;
}
Enter fullscreen mode Exit fullscreen mode

That's it. No API route, no fetch call, no complex setup. Just a function that runs on the server. You can call it from anywhere in your app.

Basic Form with Server Action

The simplest way to use a Server Action is with a form. Here's a complete example:

"use server";

import { revalidatePath } from "next/cache";
import { createPost } from "./lib/posts-store";

export async function createPostAction(prevState, formData) {
  const title = formData.get("title");

  if (!title || title.trim().length === 0) {
    return {
      message: "Title is required",
      fieldErrors: { title: "Title cannot be empty" },
    };
  }

  const post = createPost(title);
  revalidatePath("/");

  return {
    message: "Post created successfully",
    fieldErrors: { title: "" },
  };
}
Enter fullscreen mode Exit fullscreen mode

Notice the function signature: it takes prevState and formData. The prevState is useful when using useActionState (we'll cover that next), and formData contains all the form fields.

Now, here's how you use it in a form:

"use client";

import { useActionState } from "react";
import { createPostAction } from "./posts-actions";
import SubmitButton from "./submit-button";

const initialState = {
  message: "",
  fieldErrors: { title: "" },
};

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

  return (
    <form action={formAction}>
      <input
        name="title"
        required
        disabled={pending}
      />
      {state.fieldErrors.title && (
        <p role="alert" className="text-red-500">
          {state.fieldErrors.title}
        </p>
      )}
      <SubmitButton />
      <p aria-live="polite">{state.message}</p>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

The useActionState hook gives you the current state, the form action, and a pending flag. You can disable inputs while the form is submitting, show error messages, and display success messagesβ€”all handled automatically.

Form Validation with Zod

Validation is crucial for any form. Here's how to use Zod for server-side validation:

"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";
import { createPost } from "./lib/posts-store";

const createPostSchema = z.object({
  title: z
    .string()
    .trim()
    .min(1, "Title is required")
    .max(80, "Title is too long"),
});

export async function createPostAction(prevState, formData) {
  const raw = Object.fromEntries(formData);
  const result = createPostSchema.safeParse(raw);

  if (!result.success) {
    const fieldErrors = result.error.flatten().fieldErrors;
    return {
      message: "Please fix the errors and try again.",
      fieldErrors: { title: fieldErrors.title?.[0] || "" },
    };
  }

  const post = createPost(result.data.title);
  revalidatePath("/");

  return {
    message: "Post added.",
    fieldErrors: { title: "" },
  };
}
Enter fullscreen mode Exit fullscreen mode

Zod's safeParse returns an object with a success boolean. If validation fails, we extract the field errors and return them. If it succeeds, we process the data and return a success message. The form component automatically picks up these errors and displays them.

For more advanced form validation patterns, check out my React Hook Form with Zod validation guide.

Loading States with useFormStatus

Showing loading states during form submission improves user experience. The useFormStatus hook makes this easy:

"use client";

import { useFormStatus } from "react-dom";

export default function SubmitButton() {
  const status = useFormStatus();

  return (
    <button type="submit" disabled={status.pending}>
      {status.pending ? "Adding..." : "Add post"}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The useFormStatus hook must be used inside a component that's a child of a form with a Server Action. It provides a pending state that's true while the action is running. Perfect for disabling buttons and showing loading text.

Working with Cookies

Server Actions can read and write cookies. Here's an example that stores the last created post title:

"use server";

import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { z } from "zod";
import { createPost } from "./lib/posts-store";

const createPostSchema = z.object({
  title: z
    .string()
    .trim()
    .min(1, "Title is required")
    .max(80, "Title is too long"),
});

export async function createPostAction(prevState, formData) {
  const cookieStore = await cookies();
  const raw = Object.fromEntries(formData);
  const result = createPostSchema.safeParse(raw);

  if (!result.success) {
    const fieldErrors = result.error.flatten().fieldErrors;
    return {
      message: "Please fix the errors and try again.",
      fieldErrors: { title: fieldErrors.title?.[0] || "" },
    };
  }

  const post = createPost(result.data.title);
  revalidatePath("/");

  // Store the last created title in a cookie
  cookieStore.set("lastCreatedTitle", post.title);

  return {
    message: "Post added.",
    fieldErrors: { title: "" },
  };
}
Enter fullscreen mode Exit fullscreen mode

You can read cookies in server components and display them:

import { cookies } from "next/headers";
import CreatePostForm from "./create-post-form";

export default async function Page() {
  const cookieStore = await cookies();
  const lastCreatedTitle = cookieStore.get("lastCreatedTitle")?.value || "";

  return (
    <main>
      <CreatePostForm />
      {lastCreatedTitle && (
        <p>Last created: {lastCreatedTitle}</p>
      )}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Updating and Deleting with Server Actions

Server Actions work great for updates and deletes too. Here's how to handle both:

"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";
import { updatePostTitle, deletePost } from "./lib/posts-store";

const createPostSchema = z.object({
  title: z
    .string()
    .trim()
    .min(1, "Title is required")
    .max(80, "Title is too long"),
});

export async function updatePostTitleAction(postId, formData) {
  const raw = Object.fromEntries(formData);
  const result = createPostSchema.safeParse(raw);

  if (!result.success) return;

  updatePostTitle(postId, result.data.title);
  revalidatePath("/");
}

export async function deletePostAction(postId) {
  deletePost(postId);
  revalidatePath("/");
}
Enter fullscreen mode Exit fullscreen mode

Notice how we use .bind() to pass the postId to the action. Here's how you'd use these actions in a form:

import { updatePostTitleAction, deletePostAction } from "./posts-actions";

export default async function Page() {
  const posts = getPosts();

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <form action={updatePostTitleAction.bind(null, post.id)}>
            <input
              name="title"
              defaultValue={post.title}
              required
            />
            <button type="submit">Save</button>
            <button
              type="submit"
              formAction={deletePostAction.bind(null, post.id)}
            >
              Delete
            </button>
          </form>
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

The formAction prop lets you have multiple submit buttons in the same form, each calling a different action. Perfect for edit and delete operations.

Inline Server Actions

You can define Server Actions directly in server components. This is useful for simple actions that don't need to be reused:

import { revalidatePath } from "next/cache";
import Form from "next/form";
import { createPost } from "./lib/posts-store";

export default async function Page() {
  async function createPostFromForm(formData) {
    "use server";
    await new Promise((resolve) => setTimeout(resolve, 800));
    const title = formData.get("title");
    createPost(title);
    revalidatePath("/");
  }

  return (
    <main>
      <Form action={createPostFromForm}>
        <input name="title" required />
        <button type="submit">Add Post</button>
      </Form>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice we're using Next.js's Form component instead of the regular HTML form. This ensures the form works correctly with Server Actions. You can also use regular forms, but Form provides better integration.

Redirecting After Actions

Sometimes you want to redirect after a successful action. Here's how:

"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { createPost } from "./lib/posts-store";

const createPostSchema = z.object({
  title: z
    .string()
    .trim()
    .min(1, "Title is required")
    .max(80, "Title is too long"),
});

export async function createPostAction(prevState, formData) {
  const raw = Object.fromEntries(formData);
  const result = createPostSchema.safeParse(raw);

  if (!result.success) {
    const fieldErrors = result.error.flatten().fieldErrors;
    return {
      message: "Please fix the errors and try again.",
      fieldErrors: { title: fieldErrors.title?.[0] || "" },
    };
  }

  const post = createPost(result.data.title);
  revalidatePath("/");

  // Redirect to the post page
  redirect(`/posts/${post.id}`);
}
Enter fullscreen mode Exit fullscreen mode

When you call redirect(), the function doesn't return. The redirect happens immediately, so any code after it won't run. This is useful for actions that should always redirect, like creating a new resource.

Progressive Enhancement

One of the best things about Server Actions is progressive enhancement. Your forms work even if JavaScript is disabled. The form submits normally, the Server Action runs, and the page updates. If JavaScript is enabled, you get the enhanced experience with loading states and instant feedback.

This means you don't need to worry about users with slow connections or disabled JavaScript. Your forms will always work.

Complete Example: Posts Management

Let's put it all together with a complete example. Here's the data store:

// lib/posts-store.js
let posts = [
  { id: "1", title: "Hello from the server" },
  { id: "2", title: "This survives refresh (until the server restarts)" },
];

export function getPosts() {
  return posts;
}

export function getPostById(id) {
  return posts.find((post) => post.id === id) || null;
}

export function createPost(title) {
  const post = { id: crypto.randomUUID(), title };
  posts = [post, ...posts];
  return post;
}

export function updatePostTitle(postId, title) {
  posts = posts.map((post) =>
    post.id === postId ? { ...post, title } : post
  );
}

export function deletePost(postId) {
  posts = posts.filter((post) => post.id !== postId);
}
Enter fullscreen mode Exit fullscreen mode

And here's the complete Server Actions file with all operations:

"use server";

import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { z } from "zod";
import { createPost, deletePost, updatePostTitle } from "./lib/posts-store";

const createPostSchema = z.object({
  title: z
    .string()
    .trim()
    .min(1, "Title is required")
    .max(80, "Title is too long"),
});

export async function createPostAction(prevState, formData) {
  const cookieStore = await cookies();
  const raw = Object.fromEntries(formData);
  const result = createPostSchema.safeParse(raw);

  if (!result.success) {
    const fieldErrors = result.error.flatten().fieldErrors;
    return {
      message: "Please fix the errors and try again.",
      fieldErrors: { title: fieldErrors.title?.[0] || "" },
    };
  }

  const post = createPost(result.data.title);
  revalidatePath("/");

  cookieStore.set("lastCreatedTitle", post.title);

  return {
    message: "Post added.",
    fieldErrors: { title: "" },
  };
}

export async function updatePostTitleAction(postId, formData) {
  const raw = Object.fromEntries(formData);
  const result = createPostSchema.safeParse(raw);

  if (!result.success) return;

  updatePostTitle(postId, result.data.title);
  revalidatePath("/");
}

export async function deletePostAction(postId) {
  deletePost(postId);
  revalidatePath("/");
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Always validate input on the server - Even if you validate on the client, server-side validation is essential for security
  2. Use Zod or similar libraries - For type-safe validation that matches your TypeScript types
  3. Return clear error messages - Help users understand what went wrong and how to fix it
  4. Use revalidatePath or revalidateTag - After mutations to keep data fresh. Learn more about Next.js caching strategies
  5. Handle loading states - Use useFormStatus for better UX
  6. Use useActionState - For form state management
  7. Keep Server Actions focused - Single responsibility principle applies here too
  8. Use TypeScript - For type safety in Server Actions
  9. Test progressive enhancement - Test your forms with JavaScript disabled

Common Patterns

Pattern 1: Form with Validation

// Server Action
export async function submitForm(prevState, formData) {
  const result = schema.safeParse(Object.fromEntries(formData));
  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }
  // Process data
  return { success: true };
}

// Client Component
const [state, formAction] = useActionState(submitForm, { errors: {} });
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Multiple Actions in One Form

<form action={updateAction.bind(null, id)}>
  <input name="title" />
  <button type="submit">Update</button>
  <button type="submit" formAction={deleteAction.bind(null, id)}>
    Delete
  </button>
</form>
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Inline Actions

export default async function Page() {
  async function handleSubmit(formData) {
    "use server";
    // Process form
  }

  return <Form action={handleSubmit}>...</Form>;
}
Enter fullscreen mode Exit fullscreen mode

Resources and Further Reading

Conclusion

Server Actions have become my go-to solution for form handling in Next.js. They eliminate the need for API routes, simplify error handling, and provide progressive enhancement out of the box. Once you get used to them, going back to the old way feels unnecessarily complicated.

The examples in this guide cover everything you need to get started: basic forms, validation, loading states, updates, deletes, cookies, and redirects. Start with the simple examples and gradually add more complexity as you need it. Before you know it, you'll be building forms faster than ever.

Key Takeaways:

  • Server Actions eliminate the need for API routes
  • Progressive enhancement works automatically
  • Use useActionState for form state management
  • Use useFormStatus for loading states
  • Always validate on the server with Zod
  • Use revalidatePath or revalidateTag after mutations
  • Test with JavaScript disabled to ensure progressive enhancement

What's your experience with Next.js Server Actions? Share your tips and tricks in the comments below! πŸš€


πŸ’‘ Looking for more details? This is a condensed version of my comprehensive guide. Read the full article on my blog for additional examples, advanced patterns, troubleshooting tips, and more in-depth explanations.

If you found this guide helpful, consider checking out my other articles on Next.js development and web development best practices.

Top comments (0)