Hirefullstack – Software Engineering & IT-Beratung aus Berlin
← Back to overview
Core 14 min read

Pitfalls

The mistakes that happen once in almost every Next.js project

In one sentence

Eight patterns that keep coming up – each with the thinking error behind it and the shortest fix.

If you have read the previous lessons you know the building blocks. This one collects the cases where they get assembled wrongly in everyday work.

1. “use client” at the top of the page

the whole page moves to the browser
"use client";

export default function Page() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <HugeList />
      <button onClick={() => setOpen(true)}>Filter</button>
    </>
  );
}
only the interactive part
export default async function Page() {
  const data = await load();
  return (
    <>
      <HugeList data={data} />
      <FilterButton />         {/* only this file is client */}
    </>
  );
}

Why it happens: the error message points at the page, so that is where the line goes. The right move is to extract the interactive part.

2. Calling your own API from your own server

Not like this
// in a server component
const res = await fetch("https://my-site.com/api/posts");
const posts = await res.json();
Do this instead
// in a server component
const posts = await db.post.findMany();

A network detour to yourself, plus serialisation to JSON and back. Route handlerA `route.ts` that answers an address without being a page – your way to a custom API. exist for access from outside.

3. Investigating caching in development mode

Pitfall In dev mode you cannot see the problem

Almost nothing is cached there. To find out whether a page goes static you need npm run build && npm run start – and the symbol column of the build output.

4. Using params directly

wrong since Next.js 15
export default function Page({ params }: { params: { slug: string } }) {
  return <h1>{params.slug}</h1>;
}
correct
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return <h1>{slug}</h1>;
}

The same applies to searchParams, cookies() and headers(). Older examples online still show the direct access.

5. A server action with no permission check

anyone can delete anything
"use server";

export async function remove(id: string) {
  await db.post.delete({ where: { id } });
}
the action checks for itself
"use server";

export async function remove(id: string) {
  const user = await currentUser();
  if (!user?.isAdmin) throw new Error("Not allowed");

  await db.post.delete({ where: { id } });
}

Every action gets a public endpoint. The fact that the button is only shown to admins protects nothing.

6. Not revalidating after writing

after-writing.ts
"use server";
import { revalidatePath } from "next/cache";

export async function create(formData: FormData) {
  await db.post.create({ data: … });

  revalidatePath("/blog");        // the overview
  revalidatePath("/");            // in case the latest are shown there
}
Without these lines the overview keeps showing the old state – and people go looking for the bug in the database.

7. Putting everything in one Suspense area

one spinner for everything
<Suspense fallback={<Spinner />}>
  <Post />
  <Comments />          {/* only this one is slow */}
  <Recommendations />
</Suspense>
the slow part on its own
<Post />
<Suspense fallback={<CommentSkeleton />}>
  <Comments />
</Suspense>
<Recommendations />

Otherwise the fast content waits for the slow one – exactly what streaming was meant to prevent.

8. useEffect for data that exists on the server

React habit
"use client";

function List() {
  const [data, setData] = useState([]);
  useEffect(() => {
    fetch("/api/posts").then(r => r.json()).then(setData);
  }, []);

}
the Next.js way
export default async function List() {
  const data = await db.post.findMany();

}

The second route saves the API, the loading state, the race – and the content is in the HTML straight away.

In more depth: a debugging checklist optional
checklist.txt
Page shows stale data
  1. npm run build – does it say ○ where you expected ƒ?
  2. Hard reload: fresh? → the router cache in the browser
  3. Fresh after a new build? → revalidate is missing
  4. Still stale? → the data cache, check revalidateTag

"needs useState" during the build
  → a hook in a server component.
    Extract the interactive part; do not mark the page.

Bundle suddenly much larger
  → a client boundary slipped upwards.
    Compare "First Load JS" in the build output.

Server action does nothing
  → is revalidatePath missing? Or did it throw instead of
    returning an error value?
Tip The build output is the best tool

Most of these cases are visible in the npm run build table before you open a single file. A quick glance at it on every bigger change saves long searches.

Does it stick?

6 questions on this lesson. Wrong answers show up in your stats.

Hirefullstack

Need React firepower on your team?

We have been building React and Next.js applications for clients across Germany for years – as a single expert, as reinforcement for an existing team, or as a complete Scrum team.

Talk about your project →