Next.js 14 Server Actions: The Patterns Junior Devs Always Get Wrong
Server Actions in Next.js 14 have been a game-changer for handling form submissions and data mutations directly on the server. However, as I’ve mentored junior developers, I’ve noticed a recurring set of mistakes that can lead to degraded performance, security vulnerabilities, or even broken functionality. In this article, I’ll walk through the most common pitfalls, how to avoid them, and how to implement Server Actions effectively.
Mistake #1: Skipping Validation on the Server Side
Junior developers often rely solely on client-side validation because it’s quicker to implement. But skipping server-side validation is a critical error. Client-side validation can be bypassed by disabling JavaScript or using tools like Postman, leaving your application vulnerable to bad data or malicious input.
Here’s an example of a Server Action without proper validation:
export async function createUser(formData) {
const { email, password } = Object.fromEntries(formData);
await db.user.create({
data: { email, password },
});
return { message: "User created successfully!" };
}
Problem:
If email is missing or invalid, or password is too short, this code will still attempt to create a user and might throw an unhandled database error.
Solution:
Always validate your data on the server. You can use libraries like zod or yup to enforce validation rules. Here’s how it’s done correctly:
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
export async function createUser(formData) {
const { email, password } = Object.fromEntries(formData);
try {
const validatedData = UserSchema.parse({ email, password });
await db.user.create({
data: validatedData,
});
return { message: "User created successfully!" };
} catch (error) {
return { error: "Invalid input data. Please check your inputs." };
}
}
Lesson Learned:
Server-side validation is non-negotiable. Always validate incoming data before processing it.
Mistake #2: Not Handling Errors Gracefully
Another common mistake is failing to handle errors properly. Without error boundaries or try-catch blocks, your Server Actions can crash the entire page or leave users staring at a blank screen.
Here’s an example of poor error handling:
export async function deletePost(postId) {
await db.post.delete({ where: { id: postId } });
return { message: "Post deleted successfully!" };
}
Problem:
If postId doesn’t exist or the database connection fails, this action will throw an unhandled error.
Solution:
Wrap your logic in a try-catch block and return meaningful error messages to the client.
export async function deletePost(postId) {
try {
await db.post.delete({ where: { id: postId } });
return { message: "Post deleted successfully!" };
} catch (error) {
return { error: "Failed to delete post. Please try again later." };
}
}
Lesson Learned:
Always anticipate and handle errors gracefully. This improves the user experience and makes debugging easier.
Mistake #3: Overloading Server Actions with Business Logic
Server Actions should handle data mutations and interactions with the database. However, junior developers often cram too much business logic into a single action, making it hard to maintain or test.
Here’s an example of an overloaded Server Action:
export async function checkout(userId, cartItems) {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error("User not found");
}
const totalPrice = cartItems.reduce((sum, item) => sum + item.price, 0);
if (user.balance < totalPrice) {
throw new Error("Insufficient balance");
}
await db.order.createMany({
data: cartItems.map((item) => ({
userId,
productId: item.id,
price: item.price,
})),
});
await db.user.update({
where: { id: userId },
data: { balance: user.balance - totalPrice },
});
sendEmail(user.email, "Your order has been placed.");
return { message: "Order placed successfully!" };
}
Problem:
This action handles user validation, price calculation, order creation, balance updates, and email sending— all in one place. It’s tightly coupled and hard to test.
Solution:
Break your logic into smaller, reusable functions.
export async function checkout(userId, cartItems) {
const user = await validateUser(userId);
const totalPrice = calculateTotalPrice(cartItems);
if (user.balance < totalPrice) {
throw new Error("Insufficient balance");
}
await createOrder(userId, cartItems);
await updateUserBalance(userId, user.balance - totalPrice);
await sendOrderConfirmationEmail(user.email);
return { message: "Order placed successfully!" };
}
async function validateUser(userId) {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) throw new Error("User not found");
return user;
}
function calculateTotalPrice(cartItems) {
return cartItems.reduce((sum, item) => sum + item.price, 0);
}
async function createOrder(userId, cartItems) {
await db.order.createMany({
data: cartItems.map((item) => ({
userId,
productId: item.id,
price: item.price,
})),
});
}
async function updateUserBalance(userId, newBalance) {
await db.user.update({
where: { id: userId },
data: { balance: newBalance },
});
}
async function sendOrderConfirmationEmail(email) {
await sendEmail(email, "Your order has been placed.");
}
Lesson Learned:
Keep Server Actions focused and delegate complex logic to helper functions. This promotes modularity and testability.
Mistake #4: Ignoring Error Boundaries
Next.js 14 introduced error boundaries for Server Actions, but junior developers often forget to use them. Without error boundaries, unhandled errors can crash your entire application.
Here’s an example of a Server Action without an error boundary:
export async function fetchUserData(userId) {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) throw new Error("User not found");
return user;
}
Problem:
If userId doesn’t exist, this action will throw an unhandled error, potentially crashing the page.
Solution:
Wrap your Server Action in an error boundary to gracefully handle failures.
"use client";
import { useErrorBoundary } from "react-error-boundary";
export async function fetchUserData(userId) {
try {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) throw new Error("User not found");
return user;
} catch (error) {
useErrorBoundary(error);
}
}
Lesson Learned:
Use error boundaries to isolate and handle errors effectively, preventing them from affecting the entire application.
Conclusion
Server Actions in Next.js 14 are powerful, but they require careful implementation to avoid common pitfalls. By validating data, handling errors, keeping actions focused, and using error boundaries, you can build robust and maintainable applications. As I’ve worked with junior developers, I’ve seen these patterns make a massive difference in code quality and user experience. So, take the time to implement these best practices—it’s worth it.
⚡ Want the Full Prompt Library?
I compiled all of these patterns (plus 40+ more) into the Senior React Developer AI Cookbook — $19, instant download. Covers Server Actions, hydration debugging, component architecture, and real production prompts.
Browse all developer tools at apolloagmanager.github.io/apollo-ai-store
Top comments (0)