
Common Next.js Mistakes (And How to Avoid Them Like a Pro)
A comprehensive guide to avoiding the most common Next.js pitfalls. Learn how to properly use Server Components, Server Actions, caching, and more.
π Common Next.js Mistakes (And How to Avoid Them Like a Pro)
β±οΈ Reading Time: ~20 minutes
Introduction
Next.js is powerful.
Server Components, App Router, Server Actions, caching, streamingβeverything is designed for performance and scalability.
Butβ¦
If you use it the wrong way, you can easily destroy those benefits without even realizing it.
After building and reviewing multiple production-grade apps, I've seen the same mistakes again and again. So I decided to compile this practical checklist you can use before shipping your next feature.
Let's dive in.
1. Marking the Root Page as a Client Component
β Mistake
Adding "use client" to the root page forces the entire component tree to run on the client. This removes most of the performance benefits of Server Components, including server-side rendering, streaming, and caching.
This usually happens because developers assume every interactive UI needs client componentsβwhich is not true in Next.js.
"use client";
export default function Page() {
return <Dashboard />;
}Here, the whole page and all child components become client-side.
β Solution
Only mark the smallest interactive components as client components. Keep pages and layouts server-side whenever possible.
// page.tsx (Server Component)
export default function Page() {
return <Dashboard />;
}// components/Counter.tsx
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}Now, only the interactive part runs on the client.
2. Not Protecting Server Actions
β Mistake
Server Actions are exposed as public HTTP endpoints.
If you don't protect them, anyone can trigger themβleading to data leaks, unauthorized changes, or security breaches.
Many developers assume Server Actions are private by default. They are not.
"use server";
export async function deleteUser(id: string) {
await db.user.delete({ where: { id } });
}This action can be called by anyone.
β Solution
Always implement proper protection: authentication, validation, and authorization.
"use server";
import { auth } from "@/lib/auth";
export async function deleteUser(id: string) {
const session = await auth();
if (!session) {
throw new Error("Unauthorized");
}
await db.user.delete({ where: { id } });
}Now, only authenticated users can perform the action.
3. Placing Data Fetching Inside Server Action Files
β Mistake
Any file that contains "use server" turns all its exports into public endpoints.
If you put read-only fetch functions there, you unintentionally expose internal data.
"use server";
export async function getUsers() {
return db.user.findMany();
}This fetch function is now publicly accessible.
β Solution
Separate read and write logic.
// lib/data.ts
export async function getUsers() {
return db.user.findMany();
}// actions.ts
"use server";
export async function createUser(data: any) {
await db.user.create({ data });
}Now, only mutations are exposed.
4. Shipping AI-Generated Code Without Review
β Mistake
AI tools can write code quickly, but they often miss:
- Edge cases
- Validation
- Type safety
- Security rules
Shipping AI-generated code directly to production can introduce serious bugs.
// Generated by AI
function sum(a, b) {
return a + b;
}No types. No validation.
β Solution
Always review and improve AI-generated code.
function calculateSum(a: number, b: number): number {
if (a < 0 || b < 0) {
throw new Error("Invalid input");
}
return a + b;
}Now the function is safe and maintainable.
5. Using API Routes for Simple GET Requests
β Mistake
Creating separate API routes for simple GET requests adds unnecessary complexity and often forces client-side fetching.
// app/api/posts/route.ts
export async function GET() {
const posts = await getPosts();
return Response.json(posts);
}"use client";
useEffect(() => {
fetch("/api/posts")
.then((res) => res.json())
.then(setPosts);
}, []);β Solution
Fetch data directly inside Server Components.
// page.tsx
export default async function Page() {
const posts = await getPosts();
return <PostList posts={posts} />;
}Use Suspense instead of useState.
6. Placing a Suspense Boundary at the Wrong Level
β Mistake
Wrapping Suspense around an async call does not trigger loading UI correctly.
<Suspense fallback={<Loading />}>{getPosts()}</Suspense>Suspense cannot control rendering here.
β Solution
Wrap the component that consumes the data.
<Suspense fallback={<Loading />}>
<PostList />
</Suspense>async function PostList() {
const posts = await getPosts();
return <List posts={posts} />;
}7. Mixing Up use cache and use cache private
β Mistake
Using shared cache for user-specific data can cause data leakage.
"use cache";
export async function getCart() {
return getUserCart();
}β Solution
Use private cache for user data.
"use cache private";
export async function getCart() {
return getUserCart();
}8. Incorrectly Using Context Providers in App Router
β Mistake
Wrapping layout directly with a client provider makes everything client-side.
"use client";
export default function RootLayout({ children }) {
return <ThemeProvider>{children}</ThemeProvider>;
}β Solution
Create a separate provider wrapper.
// Providers.tsx
"use client";
export function Providers({ children }) {
return <ThemeProvider>{children}</ThemeProvider>;
}// layout.tsx
export default function RootLayout({ children }) {
return <Providers>{children}</Providers>;
}9. Using Browser APIs in Server Components
β Mistake
Server Components cannot access browser APIs.
console.log(window.location.href);This crashes on the server.
β Solution
Move browser logic to client components.
"use client";
import { useEffect } from "react";
export function LocationLogger() {
useEffect(() => {
console.log(window.location.href);
}, []);
return null;
}10. Adding "use client" Unnecessarily
β Mistake
Forms and buttons don't always need client components.
"use client";
export default function Form() {
return <form action={saveData}></form>;
}β Solution
Let Server Actions handle it.
export default function Form() {
return <form action={saveData}></form>;
}11. Redirecting Inside a Try-Catch Block
β Mistake
redirect() works by throwing an internal error. Catching it breaks navigation.
try {
redirect("/login");
} catch (err) {
console.log(err);
}β Solution
Call redirect outside try-catch or rethrow.
redirect("/login");or
catch (err) {
throw err;
}12. Forcing Highly Interactive UIs to Be Server Components
β Mistake
Interactive components on the server cause lag.
async function Counter() {
return <button>+</button>;
}β Solution
Use client components.
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}13. Not Using loading.tsx for Loading States
β Mistake
Using useState for loading forces client-side code.
const [loading, setLoading] = useState(true);β Solution
Use route-level loading UI.
// app/posts/loading.tsx
export default function Loading() {
return <Spinner />;
}Next.js handles it automatically.
Conclusion
Next.js is incredibly powerful when used correctly. By avoiding these common mistakes, you'll build faster, more secure, and more maintainable applications.
Remember:
β
Keep Server Components as the default
β
Protect all Server Actions
β
Use proper caching strategies
β
Leverage Next.js features like loading.tsx and Suspense
β
Always review AI-generated code
Want to dive deeper? Check out the official Next.js documentation for more best practices.
Happy coding! π
