DEV Community

Cover image for Next.js Quick Guide to Server Actions (App Router)
Alvison Hunter Arnuero | Front-End Web Developer
Alvison Hunter Arnuero | Front-End Web Developer

Posted on • Edited on

Next.js Quick Guide to Server Actions (App Router)

Ever heard of NextJS Server Actions? It’s one of those buzzwords floating around modern web development—but what does it really mean? If you’re curious (or just a bit lost), let me share my modern take on it with Next.js 15:

Wait… What the Heck Are Server Actions in NextJS?
This is a perfectly valid first question. Let’s take a moment to address it.

A Server Action is simply an async function marked with the 'use server' directive. This directive tells Next.js to treat the function as a secure, server-only endpoint.

Think of Server Actions like this:
Instead of sending a letter (API request) to another building (API route), you’re now talking directly to the person in the next room (the server).

The Evolution: Before vs. After Server Actions

Before Server Actions, a simple form required:

  • A form component
  • A fetch call or axios request
  • An /api route handler
  • JSON parsing
  • Complex error handling
  • Extra boilerplate for state management

With Server Actions, you:

  • Write one server function
  • Call it directly from your form
  • Let Next.js handle the wiring

This makes your app:

  • Simpler - Less code, fewer moving parts
  • More secure - Server code never leaks to client
  • Easier to reason about - Logic stays where it's used
  • More accessible - Works without JavaScript

Eliminating API Route Middleman with Server Actions

Next.js 15 fully embraces React Server Actions, transforming how full-stack developers manage data mutations. Instead of creating and maintaining separate API routes (/api/*) for simple form submissions, you can now run secure, server-side logic directly from your components, reducing boilerplate, simplifying architecture, and making full-stack development feel more seamless than ever.

But what does that actually mean?

Instead of creating separate API Routes (like /api/*) just to handle simple form submissions or small server-side tasks, you can now execute secure server-side logic directly from your component code. Yes—your components can talk to the server without needing extra API endpoints for internal operations.

Important Distinctions to Remember:

  • Server Actions replace API routes only for internal app logic — things your app needs internally but doesn't expose publicly
  • You still need API routes when you want to expose data publicly or allow third-party clients to interact with your backend
  • Server Components can use Server Actions directly — no "use client" needed!

This change simplifies development, reduces boilerplate, and keeps your server logic tightly connected to your components—making Full-Stack coding more intuitive than ever.

How to Define and Use a Server Action

As mentioned earlier in this article, a Server Action is simply an async function that includes the 'use server' directive. This directive signals to Next.js that the function must run exclusively on the server, allowing it to act as a secure, server-only endpoint.

As a result, the code is never exposed to the browser, making it ideal for handling sensitive logic such as database operations, authentication, or form submissions.

1. Define the Action (Server Code)

Where should this live within the app’s folder structure?

For a clean, centralized, maintainable, and scalable setup, it’s best to place it in a dedicated Server Actions directory. This keeps your server-only logic separate from client components, your codebase clean, modular, and easy to maintain and makes your project easier to navigate. For example:

src/
 ├─ actions/
 │   └─ post.ts        // Server Action (server-only)
 ├─ components/
 │   └─ NewPostForm.tsx // Client Component
 └─ app/
     └─ blog/
         └─ page.tsx   // Server Component
Enter fullscreen mode Exit fullscreen mode
  • actions/ – contains server-only functions, like API calls or database interactions.

  • components/ – contains client-side React components, such as forms or UI elements.

  • app/ – contains server components that can use Server Actions directly

This separation clearly distinguishes between server logic and client code, which improves readability, scalability, and maintainability.

Modern Server Action with Zod Validation (Next.js 15)

// src/actions/post.ts
"use server";

import { revalidatePath, revalidateTag } from "next/cache";
import { z } from "zod";

// Define schema for type safety
const createPostSchema = z.object({
  title: z.string().min(1, "Title is required").max(100),
  content: z.string().min(1, "Content is required").max(5000),
});

export type FormState = {
  success: boolean;
  message: string;
  errors?: Record<string, string[]>;
};

export async function createNewPost(
  prevState: FormState | null,
  formData: FormData
): Promise<FormState> {
  try {
    // Validate with Zod
    const result = createPostSchema.safeParse({
      title: formData.get("title"),
      content: formData.get("content"),
    });

    if (!result.success) {
      return {
        success: false,
        message: "Validation failed",
        errors: result.error.flatten().fieldErrors,
      };
    }

    const { title, content } = result.data;

    // Secure database operation (server-only)
    // await db.posts.create({
    //   data: { title, content },
    // });

    // Modern cache revalidation approaches
    revalidatePath("/blog"); // Revalidate specific path
    revalidateTag("posts"); // Revalidate by tag (for fetch caching)
    // Alternatively: await revalidatePath("/blog", "layout")

    return {
      success: true,
      message: "Post created successfully!",
    };
  } catch (error) {
    console.error("Create post error:", error);

    // More specific error handling
    if (error instanceof Error) {
      return {
        success: false,
        message: `Database error: ${error.message}`,
      };
    }

    return {
      success: false,
      message: "Something went wrong. Please try again.",
    };
  }
}

// Bonus: Additional Server Action example with auth
export async function publishPost(
  postId: string,
  shouldPublish: boolean
) {
  "use server";

  // Example: Check authentication
  // const session = await auth();
  // if (!session) {
  //   throw new Error("Unauthorized");
  // }

  // await db.posts.update({
  //   where: { id: postId },
  //   data: { published: shouldPublish },
  // });

  revalidateTag("posts");
}
Enter fullscreen mode Exit fullscreen mode

Why This Method Is Effective and Secure

  • Executes exclusively on the server, never in the browser
  • Database credentials remain fully protected and are never exposed
  • Eliminates the need for custom API routes for internal logic
  • Includes robust, built-in error handling for safer execution
  • Automatically revalidates and updates the cache, keeping data in sync
  • Type-safe with Zod validation
  • Proper error boundaries and status codes

This approach results in cleaner architecture, improved security, and better performance with minimal overhead.

Client Component: Triggering Server Actions from the UI

// src/components/NewPostForm.tsx
"use client";

import { useActionState, useEffect } from "react";
import { useFormStatus } from "react-dom";
import { createNewPost, type FormState } from "@/actions/post";

const initialState: FormState = {
  success: false,
  message: "",
  errors: {},
};

// Submit button component using useFormStatus
function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
    >
      {pending ? "Creating..." : "Create Post"}
    </button>
  );
}

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

  // Optional: Reset form on success
  useEffect(() => {
    if (state.success) {
      const form = document.querySelector("form");
      form?.reset();
    }
  }, [state.success]);

  return (
    <form action={formAction} className="space-y-4 max-w-md">
      <div>
        <input
          type="text"
          name="title"
          required
          placeholder="Post Title"
          className="w-full border p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          aria-invalid={!!state.errors?.title}
          aria-describedby={state.errors?.title ? "title-error" : undefined}
        />
        {state.errors?.title && (
          <p id="title-error" className="text-red-500 text-sm mt-1">
            {state.errors.title.join(", ")}
          </p>
        )}
      </div>

      <div>
        <textarea
          name="content"
          required
          rows={4}
          placeholder="Post Content"
          className="w-full border p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          aria-invalid={!!state.errors?.content}
          aria-describedby={state.errors?.content ? "content-error" : undefined}
        />
        {state.errors?.content && (
          <p id="content-error" className="text-red-500 text-sm mt-1">
            {state.errors.content.join(", ")}
          </p>
        )}
      </div>

      <SubmitButton />

      {state.message && (
        <p
          className={`text-sm p-3 rounded ${
            state.success 
              ? "text-green-800 bg-green-50 border border-green-200" 
              : "text-red-800 bg-red-50 border border-red-200"
          }`}
          role="alert"
        >
          {state.message}
        </p>
      )}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Server Component Example Using Server Actions Directly

Here's the powerful pattern many developers miss: using Server Actions directly in Server Components:

// src/app/blog/page.tsx
// import { db } from "@/lib/db";
import { publishPost } from "@/actions/post";

export default async function BlogPage() {
  // Server-side data fetching
  // const posts = await db.posts.findMany({
  //   where: { published: true },
  //   orderBy: { createdAt: "desc" },
  //   take: 10,
  // });

  const posts = []; // Example placeholder

  return (
    <div className="space-y-6">
      <h1 className="text-3xl font-bold">Blog Posts</h1>

      {/* Direct Server Action usage in Server Component - no "use client" needed! */}
      <form action={publishPost.bind(null, "post-id-here", true)}>
        <button
          type="submit"
          className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
        >
          Publish Post (Server Component)
        </button>
      </form>

      <div className="grid gap-4">
        {posts.map((post) => (
          <article key={post.id} className="border p-4 rounded">
            <h2 className="text-xl font-semibold">{post.title}</h2>
            <p className="text-gray-600">{post.content}</p>
          </article>
        ))}
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Why useActionState Is a Big Deal with Server Actions?

Imagine filling out and submitting a form on a website.

  • Without Server Actions → Everything depends on JavaScript. If JavaScript fails to load, errors out, or is blocked, the form simply doesn’t work.
  • With Server Actions → The browser can submit the form directly to the server, even if JavaScript is slow or completely unavailable.

This approach is called progressive enhancement — you start with a solid, reliable experience, then layer on JavaScript for extra interactivity instead of making it a requirement.

Why This Matters

  • Better accessibility - Works naturally with screen readers and assistive technologies
  • More inclusive - Performs well on slow networks, older devices, or low-end hardware
  • More resilient apps - Fewer JavaScript dependencies means fewer points of failure
  • Less client-side JavaScript - Smaller bundles, faster load times, and improved performance overall

In short, useActionState helps you build apps that work first, then get better — instead of breaking when JavaScript isn’t perfect.

Advanced Server Action Patterns

Optimistic Updates with useOptimistic

"use client";

import { useOptimistic } from "react";

function LikeButton({ initialLikes, postId }: { initialLikes: number; postId: string }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, amount: number) => state + amount
  );

  async function handleLike() {
    addOptimisticLike(1);
    // await likePost(postId); // Server Action call
  }

  return (
    <button 
      onClick={handleLike}
      className="flex items-center gap-2 px-3 py-1 rounded-full border hover:bg-gray-50"
    >
      ❤️ {optimisticLikes} Likes
    </button>
  );
}

Enter fullscreen mode Exit fullscreen mode

Inline Server Actions (Quick Prototypes)

// Inline Server Action example - great for quick prototyping
export default function ContactForm() {
  async function handleSubmit(formData: FormData) {
    "use server";

    const email = formData.get("email");
    const message = formData.get("message");

    // Process form data
    // await sendEmail(email, message);

    revalidatePath("/contact");
  }

  return (
    <form action={handleSubmit} className="space-y-4">
      <input type="email" name="email" required />
      <textarea name="message" required />
      <button type="submit">Send Message</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

When to Use Server Actions (and When Not To)

Perfect Use Cases:

  • Form submissions (contact forms, login, signup)
  • Database mutations (CRUD operations)
  • File uploads (with proper validation, < 4MB)
  • Server-side calculations (sensitive business logic)
  • Payment processing (initial request handling)
  • Email sending (transactional emails)
  • Cache invalidation (revalidating specific routes/tags)

When NOT to Use Server Actions:

  • Public APIs - Use Route Handlers (app/api/route.ts)
  • Third-party integrations requiring webhooks
  • External clients (mobile apps, desktop apps)
  • Large file uploads (> 4MB - use dedicated upload services)
  • Long-running processes (> 10 seconds - use background jobs)
  • Real-time bidirectional communication (use WebSockets)

Hybrid Approach (Route Handlers + Server Actions):

// app/api/posts/route.ts - For public API
export async function POST(request: Request) {
  const data = await request.json();

  // Reuse your Server Action logic
  // const result = await createNewPost(null, data);

  return Response.json({ success: true }); // result
}

// Then in your form, still use Server Actions directly
// but third parties can use the API route
Enter fullscreen mode Exit fullscreen mode

Security Best Practices

  • Always validate on the server - client validation is optional UX enhancement
  • Use headers() and cookies() for auth - don't pass tokens in props
  • Implement rate limiting - using middleware or Redis
  • Sanitize HTML input - prevent XSS attacks
  • Use database transactions for multiple operations
  • Set proper CORS headers when needed
"use server";

import { headers, cookies } from "next/headers";

export async function secureAction() {
  // Get request headers
  const headerStore = await headers();
  const authHeader = headerStore.get("authorization");

  // Get cookies
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get("session")?.value;

  // Your secure logic here
}
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

  • Keep Server Actions small - offload heavy processing to background jobs
  • Use streaming for long operations - better UX
  • Implement proper caching strategies - reduce database load
  • Consider edge runtime for global performance:
export const runtime = "edge"; // Optional: for Server Actions on edge
Enter fullscreen mode Exit fullscreen mode

What You Learned

By leveraging useActionState, the form gets progressive enhancement by default, meaning it works even if JavaScript is disabled—a key win for accessibility and reliability in modern Web Development.

The Server Action pattern is central to the full-stack architecture espoused by most modern developers nowadays.

Key Takeaways:

  • Server Actions let you run server code directly from forms
  • You no longer need /api routes for simple mutations
  • Your database logic stays secure and never leaks to client
  • Next.js handles caching, validation flow, and form submission
  • Your app becomes simpler, more secure, and more reliable
  • Server Components can use Server Actions directly
  • Type safety with Zod validation is crucial for production
  • Progressive enhancement makes apps work without JavaScript

The Bottom Line: Server Actions in Next.js 15 represent a paradigm shift toward more integrated, secure, and performant full-stack development. They eliminate unnecessary boilerplate while maintaining security boundaries, making it easier than ever to build robust applications that work for everyone, regardless of their device or network conditions.

Whether you're building a simple contact form or a complex dashboard, Server Actions provide the right abstraction to keep your code clean, secure, and maintainable. Start using them today and experience the simplicity of true full-stack development with Next.js 15!


About the Author

Alvison Hunter is a Full-Stack Software Engineer with strong specialization in frontend engineering and modern JavaScript ecosystems. He builds fast, scalable, and SEO-optimized web applications using React, Next.js, Vue, Node.js, and cloud-native architectures.

With a deep focus on clean UI design, performance, and maintainable code, Alvison helps businesses and creators turn ideas into reliable digital products.

👉 Explore custom React, NextJS & Vue web development, frontend architecture, and full-stack solutions at https://www.codecrafterslabs.com

Find Alvison Hunter online:


❤️ Enjoyed the article? Your feedback fuels more content.
💬 Share your thoughts in a comment.
🔖 No time to read now? Well, Bookmark for later.
🔗 If it helped, pass it on, dude!

Top comments (0)