Post 05

Things to Stop Doing in Next.js in 2025

If you call yourself a mid-level Next.js developer and still write an API route for every form, sprinkle 'use client' like seasoning, and fetch inside client components — this one is for you. With the code for what to do instead.

May 10, 2025/10 min readNext.js
ShareY
Things to Stop Doing in Next.js in 2025

This is not a beginner post. If you are shipping Next.js apps professionally and still doing these things, they are costing you performance, maintainability, or both. Respectfully: stop.

Writing an API route for every form

The reflex from the Pages Router era was: form submits, fetch('/api/thing'), a route.ts handler parses the body, does the work, returns JSON. In the App Router, most of that is a Server Action.

the old shape — three files, no types across the wire
1// app/api/subscribe/route.ts2export async function POST(req: Request) {3  const { email } = await req.json();4  if (!email) return Response.json({ error: "bad" }, { status: 400 });5  await addSubscriber(email);6  return Response.json({ ok: true });7}8 9// components/form.tsx10const res = await fetch("/api/subscribe", {11  method: "POST",12  headers: { "content-type": "application/json" },13  body: JSON.stringify({ email }),14});
the shape you want — one file, typed, revalidates
1// app/subscribe/actions.ts2"use server";3import { z } from "zod";4import { revalidatePath } from "next/cache";5 6const schema = z.object({ email: z.string().email() });7 8export async function subscribe(_: unknown, formData: FormData) {9  const parsed = schema.safeParse(Object.fromEntries(formData));10  if (!parsed.success) return { error: "Enter a valid email" };11  await addSubscriber(parsed.data.email);12  revalidatePath("/subscribers");13  return { ok: true };14}15 16// components/form.tsx17import { subscribe } from "@/app/subscribe/actions";18<form action={subscribe}>…</form>
Note

Keep Route Handlers for the things that genuinely need an HTTP surface: webhooks, OAuth callbacks, public APIs, endpoints a mobile client consumes. Everything your own front end calls should be a Server Action.

Putting 'use client' at the top of everything

Every "use client" is a commitment to ship that file and its imports as JavaScript the browser must download, parse, and hydrate. When it is on files that render static text or a list, you are paying for interactivity you are not using.

the fix — isolate the interactive bit
1// ❌ whole card is a client component for one button2"use client";3export function ProductCard({ product }: { product: Product }) {4  const [saved, setSaved] = useState(false);5  return (6    <article>7      <img src={product.image} alt="" />8      <h3>{product.name}</h3>9      <p>{product.blurb}</p>10      <button onClick={() => setSaved(!saved)}>{saved ? "Saved" : "Save"}</button>11    </article>12  );13}14 15// ✅ card stays on the server; only the button ships JS16export function ProductCard({ product }: { product: Product }) {17  return (18    <article>19      <img src={product.image} alt="" />20      <h3>{product.name}</h3>21      <p>{product.blurb}</p>22      <SaveButton productId={product.id} />23    </article>24  );25}

Fetching in a client component because it works

useEffect with fetch inside a Client Component works, in the sense that data eventually appears. It also means the request cannot start until the JS bundle loads, there is a guaranteed loading spinner, the data is not in the initial HTML, and you have hand-rolled cache and error handling.

before — a request waterfall behind hydration
1"use client";2export function Profile({ id }: { id: string }) {3  const [user, setUser] = useState<User | null>(null);4  useEffect(() => {5    fetch(`/api/users/${id}`).then((r) => r.json()).then(setUser);6  }, [id]);7  if (!user) return <Spinner />;8  return <ProfileCard user={user} />;9}
after — data is in the first byte of HTML
1// no "use client" — this is a Server Component2export async function Profile({ id }: { id: string }) {3  const user = await getUser(id);      // runs on the server, at render4  if (!user) notFound();5  return <ProfileCard user={user} />;6}
Fetching on the client when you did not have to is spending your user’s battery and bandwidth to make your page slower.

The utils/ folder that is actually a landfill

src/utils/ — a code smell
1src/utils/2  helpers.ts3  format.ts4  misc.ts5  stuff.ts        ← what is in here? nobody knows6  index.ts        ← re-exports all of the above

Group code by feature, not by file type. The date formatter for invoices lives next to the invoice code. The generic, genuinely shared helper — and there are fewer than you think — gets a real name in a real module.

by feature instead
1src/features/invoices/2  invoice-list.tsx3  invoice-row.tsx4  format-invoice-total.ts   ← lives with the thing that uses it5  actions.ts6 7src/lib/8  currency.ts               ← genuinely shared, named for what it does9  date.ts

Ignoring the caching model until it bites you

“Why is my data stale” is not a mystery once you know there are four caches. Learn which is which, be explicit about fetch caching options, tag your reads, and bust the tags in your writes. Guessing at revalidate values and adding router.refresh() until it works is not a strategy.

Treating the App Router like a renderer

Next.js is not React with a router bolted on. It is React with a server, a cache, and a data layer. If you are only using the routing, you are carrying the weight of the framework without the payoff.

Found this useful? Pass it on.

All posts

Want a custom write-up for your team? Get in touch.

Building something like this?

If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.