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.

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.
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});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>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.
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.
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}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
1src/utils/2 helpers.ts3 format.ts4 misc.ts5 stuff.ts ← what is in here? nobody knows6 index.ts ← re-exports all of the aboveGroup 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.
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.tsIgnoring 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.
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.