JB logo

Command Palette

Search for a command to run...

yOUTUBE
Blog
PreviousNext

5 Critical Server Action Mistakes That Are Breaking Your Next.js App (and How to Fix Them)

Stop making these 5 critical Server Action mistakes that are slowing down your Next.js application and creating security vulnerabilities. Learn the proper patterns for transitions, validation, authentication, error handling, and data fetching.

5 Critical Server Action Mistakes That Are Breaking Your Next.js App (and How to Fix Them)

Server Actions are one of Next.js's most powerful features, but they're also one of the most misunderstood. They allow you to send POST requests to your server without setting up API route handlers and making fetch requests. Instead, you write them as normal JavaScript functions that you can call from anywhere on your front end.

But because of this convenience, Server Actions have a lot of hidden magic going on, which makes it easy to make critical mistakes. After analyzing hundreds of Next.js applications and working with developers who've struggled with performance issues, security vulnerabilities, and buggy behavior, I've identified the 5 most critical mistakes that are secretly breaking applications.

Server Actions create public endpoints by default and require careful handling to avoid security issues and performance problems. Some of these mistakes are related to security, so you definitely don't want to skip this guide.

Mistake #1: Not Wrapping Client-Side Server Action Calls into Transitions

The Problem: When you call a Server Action from a client component that uses revalidatePath, the loading state resets too early because it doesn't wait for the revalidation to complete.

This happens because revalidatePath internally uses React's transition system, but your manual loading state doesn't know about this. The result is a jarring user experience where the loading spinner disappears before the fresh data appears on screen.

// ❌ WRONG - Loading state resets too early
'use client'
import { useState } from 'react'
import { createComment } from './actions'
 
export function CommentForm() {
  const [input, setInput] = useState('')
  const [isPending, setIsPending] = useState(false)
  const [error, setError] = useState('')
 
  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault()
 
    try {
      setIsPending(true) // Loading starts
      await createComment({ text: input })
      setInput('') // Input resets immediately
    } catch (error) {
      setError(error.message)
    } finally {
      setIsPending(false) // Loading stops too early!
      // revalidatePath is still running in the background
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        disabled={isPending}
      />
      <button disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Comment'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  )
}
// Server Action
"use server";
import { revalidatePath } from "next/cache";
 
export async function createComment(input: { text: string }) {
  // Simulate server work
  await new Promise((resolve) => setTimeout(resolve, 2000));
 
  // Add to database
  await db.comment.create({
    data: { text: input.text, userId: "user-id" },
  });
 
  // This continues running after the client thinks it's done
  revalidatePath("/comments");
}

The Fix: Use useTransition which properly waits for revalidatePath to complete:

// ✅ CORRECT - Transition waits for complete revalidation
'use client'
import { useState, useTransition } from 'react'
import { createComment } from './actions'
 
export function CommentForm() {
  const [input, setInput] = useState('')
  const [error, setError] = useState('')
  const [isPending, startTransition] = useTransition()
 
  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault()
    setError('')
 
    startTransition(async () => {
      try {
        await createComment({ text: input })
 
        // Reset input only after everything is complete
        startTransition(() => {
          setInput('')
        })
      } catch (error) {
        setError(error.message)
      }
    })
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        disabled={isPending}
      />
      <button disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Comment'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  )
}

Key Points:

  • Always wrap Server Action calls in useTransition when they use revalidatePath
  • Use nested transitions for actions that need to happen after revalidation
  • The isPending state from useTransition properly tracks the entire process
  • Input resets and UI updates should happen inside the transition

Mistake #2: Not Validating User Input Server-Side

The Problem: Server Actions look like normal functions, which gives the illusion that you don't need validation because you have TypeScript types. But remember, Server Actions create public endpoints that can be called from anywhere, not just your app.

Anyone can inspect the network requests, copy the next-action header, and call your Server Action directly from tools like Postman, completely bypassing your client-side validation.

// ❌ WRONG - No server-side validation
"use server";
 
export async function createComment(input: { text: string }) {
  // TypeScript types don't protect against malicious requests!
  // Someone could send a 10,000 character string and break your UI
 
  await db.comment.create({
    data: {
      text: input.text, // No validation!
      userId: "user-id",
    },
  });
 
  revalidatePath("/comments");
}

Here's how easy it is to bypass your frontend validation:

  1. Open Network tab in DevTools
  2. Submit a form to trigger the Server Action
  3. Copy the request URL and next-action header
  4. Use Postman to send malicious data directly to your endpoint
// Malicious request that bypasses 100-character limit
[
  {
    "text": "This is a super long comment that exceeds your 100 character limit and will break your UI layout because there's no server-side validation..."
  }
]

The Fix: Always validate on the server using a schema validation library:

// lib/validations.ts
import { z } from "zod";
 
export const createCommentSchema = z.object({
  text: z
    .string()
    .min(1, "Comment cannot be empty")
    .max(100, "Comment must be less than 100 characters")
    .refine(
      (text) => !text.includes("spam"),
      "Comment contains prohibited content"
    ),
});
 
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
// ✅ CORRECT - Proper server-side validation
"use server";
import { createCommentSchema } from "@/lib/validations";
 
export async function createComment(input: unknown) {
  // Always validate the raw input
  const result = createCommentSchema.safeParse(input);
 
  if (!result.success) {
    throw new Error("Validation failed: " + result.error.issues[0].message);
  }
 
  // Now we know the data is safe
  const { text } = result.data;
 
  await db.comment.create({
    data: {
      text,
      userId: "user-id",
    },
  });
 
  revalidatePath("/comments");
}

Advanced Validation Pattern:

// For complex validation with multiple error types
export async function createComment(
  input: unknown
): Promise<
  { success: true } | { success: false; error: string; field?: string }
> {
  const result = createCommentSchema.safeParse(input);
 
  if (!result.success) {
    const firstError = result.error.issues[0];
    return {
      success: false,
      error: firstError.message,
      field: firstError.path[0] as string,
    };
  }
 
  try {
    await db.comment.create({
      data: { text: result.data.text, userId: "user-id" },
    });
 
    revalidatePath("/comments");
    return { success: true };
  } catch (error) {
    return {
      success: false,
      error: "Failed to create comment",
    };
  }
}

Key Points:

  • Server Actions are public endpoints - treat them as such
  • Client-side validation is for UX, server-side validation is for security
  • Use libraries like Zod for robust schema validation
  • Always validate the raw input before processing

Mistake #3: Not Authenticating the User Server-Side

The Problem: Sending user data from the frontend to Server Actions is a massive security vulnerability. Never trust data from the client - anyone can modify it or send requests directly to your endpoints.

// ❌ WRONG - Trusting user data from client
"use client";
import { useUser } from "@/hooks/useUser";
 
export function CommentForm() {
  const user = useUser(); // Client-side user data
 
  const handleSubmit = async (formData: FormData) => {
    // Sending user from client - NEVER DO THIS!
    await createComment({
      text: formData.get("text"),
      user: user, // Anyone can fake this!
    });
  };
}
// ❌ WRONG - Accepting user data from client
"use server";
 
export async function createComment(input: {
  text: string;
  user: { id: string; name: string }; // Never trust this!
}) {
  // This user object could be completely fake
  await db.comment.create({
    data: {
      text: input.text,
      userId: input.user.id, // Attacker could impersonate anyone!
      authorName: input.user.name,
    },
  });
}

The Fix: Always authenticate on the server using secure session management:

// lib/auth.ts - Create a centralized auth utility
import "server-only";
import { cookies } from "next/headers";
import jwt from "jsonwebtoken";
 
export async function getCurrentUser() {
  try {
    const cookieStore = cookies();
    const token = cookieStore.get("auth-token");
 
    if (!token) {
      return null;
    }
 
    // Verify the token and get user data
    const payload = jwt.verify(token.value, process.env.JWT_SECRET!);
 
    // Fetch fresh user data from database
    const user = await db.user.findUnique({
      where: { id: payload.userId },
      select: { id: true, name: true, email: true },
    });
 
    return user;
  } catch (error) {
    return null;
  }
}
 
export async function requireUser() {
  const user = await getCurrentUser();
 
  if (!user) {
    throw new Error("Authentication required");
  }
 
  return user;
}
// ✅ CORRECT - Server-side authentication
"use server";
import { requireUser } from "@/lib/auth";
 
export async function createComment(input: { text: string }) {
  // Authenticate the user on the server
  const currentUser = await requireUser();
 
  // Validate the input
  const result = createCommentSchema.safeParse(input);
  if (!result.success) {
    throw new Error("Invalid input");
  }
 
  // Use the server-authenticated user data
  await db.comment.create({
    data: {
      text: result.data.text,
      userId: currentUser.id, // This is secure
      authorName: currentUser.name,
    },
  });
 
  revalidatePath("/comments");
}

Advanced Authentication with Role-Based Access:

// lib/auth.ts
export async function requireRole(role: string) {
  const user = await requireUser();
 
  if (!user.roles?.includes(role)) {
    throw new Error("Insufficient permissions");
  }
 
  return user;
}
 
// Server Action with role checking
export async function deleteComment(commentId: string) {
  "use server";
 
  // Only admins can delete comments
  const user = await requireRole("admin");
 
  await db.comment.delete({
    where: { id: commentId },
  });
 
  revalidatePath("/comments");
}

Key Points:

  • Never trust user data sent from the client
  • Always authenticate using server-side session management
  • Use secure tokens/cookies that can't be easily forged
  • Create centralized auth utilities to avoid code duplication
  • Consider role-based access control for sensitive operations

Mistake #4: Not Returning Errors Correctly

The Problem: In production, React automatically strips error messages from Server Actions for security reasons. If you throw errors, users will see generic messages like "The specific message is omitted in production builds to avoid leaking sensitive data."

This makes it impossible to show user-friendly error messages for expected errors like validation failures or business logic violations.

// ❌ WRONG - Throwing errors that get stripped in production
"use server";
 
export async function createComment(input: { text: string }) {
  const user = await requireUser();
 
  if (input.text.includes("spam")) {
    // This message disappears in production!
    throw new Error("No profanity allowed");
  }
 
  await db.comment.create({
    data: { text: input.text, userId: user.id },
  });
 
  revalidatePath("/comments");
}
// Client code that breaks in production
const handleSubmit = async (formData: FormData) => {
  try {
    await createComment({ text: formData.get("text") });
  } catch (error) {
    // In development: "No profanity allowed"
    // In production: "The specific message is omitted in production builds..."
    setError(error.message);
  }
};

The Fix: Return structured error objects instead of throwing:

// types/actions.ts
export type ActionResponse<T = void> =
  | { success: true; data?: T }
  | { success: false; error: string };
// ✅ CORRECT - Returning structured errors
"use server";
import type { ActionResponse } from "@/types/actions";
 
export async function createComment(input: {
  text: string;
}): Promise<ActionResponse> {
  try {
    // Authentication
    const user = await requireUser();
 
    // Validation
    const result = createCommentSchema.safeParse(input);
    if (!result.success) {
      return {
        success: false,
        error: result.error.issues[0].message,
      };
    }
 
    // Business logic validation
    if (result.data.text.includes("spam")) {
      return {
        success: false,
        error: "No profanity allowed",
      };
    }
 
    // Create comment
    await db.comment.create({
      data: {
        text: result.data.text,
        userId: user.id,
      },
    });
 
    revalidatePath("/comments");
 
    return { success: true };
  } catch (error) {
    console.error("Create comment error:", error);
 
    // Distinguish between expected and unexpected errors
    if (error.message === "Authentication required") {
      return {
        success: false,
        error: "Please sign in to comment",
      };
    }
 
    // Generic error for unexpected issues
    return {
      success: false,
      error: "Failed to create comment",
    };
  }
}
// ✅ CORRECT - Handling structured responses
const handleSubmit = async (formData: FormData) => {
  const result = await createComment({
    text: formData.get("text") as string,
  });
 
  if (result.success) {
    toast.success("Comment created!");
    setInput("");
  } else {
    // This error message works in production!
    setError(result.error);
  }
};

Advanced Error Handling with Error Types:

// Enhanced error handling with codes
export type ActionResponse<T = void> =
  | { success: true; data?: T }
  | {
      success: false;
      error: string;
      code?: "VALIDATION" | "AUTH" | "PERMISSION" | "BUSINESS" | "INTERNAL";
      details?: any;
    };
 
export async function createComment(input: {
  text: string;
}): Promise<ActionResponse> {
  try {
    const user = await requireUser();
 
    const result = createCommentSchema.safeParse(input);
    if (!result.success) {
      return {
        success: false,
        error: "Please check your input",
        code: "VALIDATION",
        details: result.error.issues,
      };
    }
 
    // ... rest of logic
  } catch (error) {
    if (error.message === "Authentication required") {
      return {
        success: false,
        error: "Please sign in to comment",
        code: "AUTH",
      };
    }
 
    return {
      success: false,
      error: "Something went wrong",
      code: "INTERNAL",
    };
  }
}

Key Points:

  • Never rely on thrown errors for user-facing messages in production
  • Return structured objects with success/error states
  • Distinguish between expected and unexpected errors
  • Use error codes for different error handling on the client
  • Always log unexpected errors for debugging

Mistake #5: Using Server Actions to Fetch Data

The Problem: Server Actions are POST endpoints, not GET endpoints. When you use them for data fetching, several critical issues arise:

  1. No Caching: POST requests can't be cached properly by browsers or CDNs
  2. Sequential Execution: Server Actions run one after another, not in parallel
  3. Performance Issues: If you fetch data and mutate data simultaneously, they queue up

This creates a terrible user experience where actions block each other unnecessarily.

// ❌ WRONG - Using Server Actions for data fetching
"use server";
 
// This creates a POST endpoint for what should be a GET request
export async function getComments(page: number = 1) {
  await new Promise((resolve) => setTimeout(resolve, 2000)); // Simulate delay
 
  const comments = await db.comment.findMany({
    skip: (page - 1) * 10,
    take: 10,
    orderBy: { createdAt: "desc" },
  });
 
  return comments;
}
 
export async function createComment(input: { text: string }) {
  await new Promise((resolve) => setTimeout(resolve, 2000)); // Simulate delay
 
  const user = await requireUser();
 
  await db.comment.create({
    data: { text: input.text, userId: user.id },
  });
 
  revalidatePath("/comments");
}
// Client code showing the problem
"use client";
import { useTransition } from "react";
 
export function CommentsPage() {
  const [isPending, startTransition] = useTransition();
 
  const loadMoreComments = () => {
    startTransition(async () => {
      // This Server Action will queue up
      const newComments = await getComments(2);
      setComments((prev) => [...prev, ...newComments]);
    });
  };
 
  const submitComment = () => {
    startTransition(async () => {
      // If loadMoreComments is still running, this waits!
      // Total time: 4 seconds instead of 2 seconds
      await createComment({ text: "New comment" });
    });
  };
 
  // User clicks "Load More" then immediately tries to submit a comment
  // The comment submission waits for the page load to complete
  // This is terrible UX!
}

The Fix: Use Route Handlers for data fetching and Server Actions only for mutations:

// ✅ CORRECT - Route Handler for data fetching
// app/api/comments/route.ts
import { NextRequest } from "next/server";
 
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const page = parseInt(searchParams.get("page") || "1");
 
  try {
    const comments = await db.comment.findMany({
      skip: (page - 1) * 10,
      take: 10,
      orderBy: { createdAt: "desc" },
      include: {
        author: {
          select: { name: true, avatar: true },
        },
      },
    });
 
    return Response.json({
      success: true,
      data: comments,
      hasMore: comments.length === 10,
    });
  } catch (error) {
    return Response.json(
      { success: false, error: "Failed to fetch comments" },
      { status: 500 }
    );
  }
}
// Server Action for mutations only
"use server";
 
export async function createComment(input: {
  text: string;
}): Promise<ActionResponse> {
  try {
    const user = await requireUser();
 
    const result = createCommentSchema.safeParse(input);
    if (!result.success) {
      return {
        success: false,
        error: result.error.issues[0].message,
      };
    }
 
    const comment = await db.comment.create({
      data: {
        text: result.data.text,
        userId: user.id,
      },
      include: {
        author: {
          select: { name: true, avatar: true },
        },
      },
    });
 
    revalidatePath("/comments");
 
    return {
      success: true,
      data: comment,
    };
  } catch (error) {
    return {
      success: false,
      error: "Failed to create comment",
    };
  }
}
// ✅ CORRECT - Client with proper separation
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
 
export function CommentsPage() {
  const queryClient = useQueryClient();
 
  // GET request for data fetching - runs in parallel
  const {
    data: comments,
    isLoading,
    fetchNextPage,
  } = useInfiniteQuery({
    queryKey: ["comments"],
    queryFn: async ({ pageParam = 1 }) => {
      const response = await fetch(`/api/comments?page=${pageParam}`);
      return response.json();
    },
    getNextPageParam: (lastPage, pages) =>
      lastPage.hasMore ? pages.length + 1 : undefined,
  });
 
  // Server Action for mutations
  const createCommentMutation = useMutation({
    mutationFn: createComment,
    onSuccess: (result) => {
      if (result.success) {
        // Optimistically update the cache
        queryClient.setQueryData(["comments"], (old) => ({
          ...old,
          pages: [{ data: [result.data], hasMore: true }, ...old.pages],
        }));
        toast.success("Comment created!");
      } else {
        toast.error(result.error);
      }
    },
  });
 
  // Now these can run simultaneously without blocking each other!
  const loadMore = () => fetchNextPage();
  const submitComment = (text: string) =>
    createCommentMutation.mutate({ text });
}

Key Points:

  • Server Actions are for mutations (POST/PUT/DELETE), not data fetching (GET)
  • Use Route Handlers (/api/*) for data fetching - they run in parallel
  • Server Actions queue up sequentially, causing performance bottlenecks
  • Route Handlers can be cached, Server Actions cannot
  • This separation follows REST principles and web standards

Conclusion

These 5 mistakes can seriously impact your Next.js application's security, performance, and user experience. Server Actions are powerful, but they require careful implementation:

Key Takeaways:

  1. Use useTransition for proper loading states with revalidatePath
  2. Always validate server-side - never trust client data
  3. Authenticate on the server using secure session management
  4. Return structured errors instead of throwing them
  5. Use Route Handlers for data fetching and Server Actions only for mutations

By avoiding these mistakes, you'll build more secure, performant, and reliable Next.js applications. Server Actions are still evolving, but following these patterns will keep your application robust as the ecosystem continues to mature.

Remember: Server Actions create public endpoints, so always treat them with the same security considerations as any other API endpoint in your application.