Web Developmentnextjsserver actionsreact

Understanding Server Actions in Next.js 15

A complete guide to Server Actions in Next.js 15, how they work, how to handle errors and pending states, and when to use them over API routes.

Codolve Team7 min read
Share
Understanding Server Actions in Next.js 15

Server Actions are one of the most significant additions to the React and Next.js ecosystem in years. They eliminate the need for a dedicated API layer for most data mutations, reduce client-side JavaScript, and provide seamless end-to-end type safety. This guide covers everything you need to use them confidently in production.

What Are Server Actions and Why Do They Matter

A Server Action is an async function marked with the "use server" directive. When called, either from a form or a client-side event handler, the function executes on the server, not in the browser. No fetch, no API route, no JSON serialisation ceremony.

This matters because:

  • No API boilerplate: you skip writing, testing, and maintaining a separate HTTP endpoint
  • Type safety: TypeScript covers your full data flow without JSON.parse boundaries
  • Progressive enhancement: forms using Server Actions work even before JavaScript loads
  • Reduced attack surface: server logic stays on the server, never exposed to the browser
// app/actions.ts
"use server";

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

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

  if (!title || title.trim().length < 3) {
    return { error: "Title must be at least 3 characters." };
  }

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

  revalidatePath("/posts");
}

Using Server Actions with Forms

The cleanest use case is HTML forms. Pass the action function directly to the action prop:

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

export default function NewPostPage() {
  return (
    <form action={createPost} className="flex flex-col gap-4 max-w-lg">
      <input
        name="title"
        placeholder="Post title"
        required
        minLength={3}
        className="border rounded-lg px-4 py-2"
      />
      <textarea
        name="content"
        placeholder="Write your content..."
        rows={8}
        className="border rounded-lg px-4 py-2"
      />
      <button
        type="submit"
        className="bg-indigo-600 text-white rounded-lg px-6 py-2 font-semibold"
      >
        Publish Post
      </button>
    </form>
  );
}

No useState, no useEffect, no fetch. The form submits to the server action directly. If JavaScript is disabled in the browser, the form still works via native HTTP form submission. That's progressive enhancement with zero extra effort.

Handling Errors and Pending States

Real applications need to communicate submission state and errors to the user. The useActionState hook handles this elegantly:

// app/posts/new/PostForm.tsx
"use client";

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

type State = { error?: string; success?: boolean } | null;

export default function PostForm() {
  const [state, action, isPending] = useActionState<State, FormData>(
    createPost,
    null
  );

  return (
    <form action={action} className="flex flex-col gap-4 max-w-lg">
      <input name="title" placeholder="Post title" required />
      <textarea name="content" placeholder="Write your content..." rows={8} />

      {state?.error && (
        <p className="text-red-600 text-sm bg-red-50 rounded-lg px-4 py-2">
          {state.error}
        </p>
      )}

      {state?.success && (
        <p className="text-green-600 text-sm bg-green-50 rounded-lg px-4 py-2">
          Post published successfully!
        </p>
      )}

      <button
        type="submit"
        disabled={isPending}
        className="bg-indigo-600 text-white rounded-lg px-6 py-2 font-semibold disabled:opacity-50"
      >
        {isPending ? "Publishing..." : "Publish Post"}
      </button>
    </form>
  );
}

Update your Server Action to return the state object:

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

export async function createPost(
  prevState: { error?: string; success?: boolean } | null,
  formData: FormData
) {
  const title = formData.get("title") as string;

  if (!title || title.trim().length < 3) {
    return { error: "Title must be at least 3 characters." };
  }

  try {
    await db.post.create({ data: { title: title.trim() } });
    revalidatePath("/posts");
    return { success: true };
  } catch {
    return { error: "Failed to create post. Please try again." };
  }
}

Optimistic Updates with useOptimistic

For a snappy UI that doesn't wait for the server round-trip, useOptimistic lets you show the expected result immediately while the actual mutation is in flight:

"use client";

import { useOptimistic, useTransition } from "react";
import { toggleLike } from "@/app/actions";

export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (current, delta: number) => current + delta
  );
  const [isPending, startTransition] = useTransition();

  function handleLike() {
    startTransition(async () => {
      addOptimisticLike(1); // Instantly update the UI
      await toggleLike(postId); // Then actually call the server
    });
  }

  return (
    <button onClick={handleLike} disabled={isPending}>
      ❤️ {optimisticLikes}
    </button>
  );
}

The like count updates immediately in the UI. If the server call fails, it reverts. This creates a native-app-like feel without any client-side state management library.

Calling Server Actions from Event Handlers

Server Actions aren't limited to forms. You can call them from any client-side event:

"use client";

import { deletePost } from "@/app/actions";

export function DeleteButton({ postId }: { postId: string }) {
  return (
    <button
      onClick={() => deletePost(postId)}
      className="text-red-600 hover:text-red-800 font-medium"
    >
      Delete
    </button>
  );
}

The deletePost function runs on the server even though it's called from a client-side onClick. Next.js handles the network transport transparently.

Security Considerations

Server Actions are still HTTP endpoints under the hood, they can be called by anyone who knows the URL. Never assume the caller is authorised. Always validate and authorise in the action itself:

"use server";

import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";

export async function deletePost(postId: string) {
  const session = await getServerSession(authOptions);

  if (!session?.user?.id) {
    throw new Error("Unauthorised");
  }

  const post = await db.post.findUnique({ where: { id: postId } });

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

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

Input validation using a library like Zod is strongly recommended:

import { z } from "zod";

const CreatePostSchema = z.object({
  title: z.string().min(3).max(200).trim(),
  content: z.string().min(10),
});

export async function createPost(_: unknown, formData: FormData) {
  const result = CreatePostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });

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

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

When to Use Server Actions vs API Routes

Both have their place. The key question: who is the consumer?

Use Server Actions when:

  • The mutation is called from your own Next.js application
  • You want type safety across the full stack
  • You want to reduce client-side JavaScript

Use API Routes when:

  • You need a publicly documented HTTP API consumed by external services
  • A mobile app or third-party service needs to call your endpoints
  • You need full control over HTTP methods, headers, and status codes
  • You're building webhooks

For most intra-application data mutation needs, Server Actions are the simpler, safer, and more type-safe choice. If you're building a full-stack web application with Next.js, Server Actions should be your first choice for form handling and data mutation.

Frequently Asked Questions

Are Server Actions secure by default?

Server Actions use POST requests with CSRF protection built in (Next.js validates the Origin header automatically). However, you must still implement authorisation logic yourself, the fact that an action runs on the server doesn't mean any user can safely call it.

Can Server Actions replace all my API routes?

For internal mutations (create, update, delete) called from your own frontend, yes. For public APIs consumed by external services, mobile apps, or webhooks, API routes are still appropriate.

Do Server Actions work without JavaScript?

Yes. When used with HTML <form action={serverAction}>, the form falls back to standard HTTP POST if JavaScript is unavailable or hasn't loaded yet. This makes them progressively enhanced by default.

How do I handle file uploads with Server Actions?

The FormData object handles file uploads natively. Access the file with formData.get("file") as File and then use a storage SDK (S3, Vercel Blob, Cloudinary) to upload from within the action.

Can I call multiple Server Actions from one form?

Yes, you can use different buttons each with their own formAction prop, or conditionally call different actions based on a hidden input value. Each button in a form can override the form's action with its own formAction.

Tags

#nextjs#server actions#react#typescript#full-stack#forms
Share
userImage1userImage2userImage3

Build impactful digital products

Ready to Start Your Next Big Project ?

Contact Us