Back to Writing
Growth status: Evergreen EvergreenUpdated: Feb 3, 20264 min read

Making Next.js Behave: Practical Notes on Performance, Architecture, and Not Shooting Yourself

JavaScript is the only language where you can take a six-month sabbatical and come back to find that your entire tech stack has been deprecated, replaced, and then 're-imagined' as a Rust-based CLI tool

image

Next.js is fast by default. It is also very easy to make slow by accident.

Most performance problems I see in Next apps are not about React being slow or Vercel being expensive. They come from unclear boundaries, duplicated work, and letting “temporary” decisions live forever.

These are notes on how to keep a Next.js codebase fast to build, predictable to run, and pleasant to work in.

Start by making the codebase legible

Before touching performance tools, make the project understandable.

If a new developer cannot tell where hooks live, where API logic lives, or where page copy lives within a few minutes, you will pay for that confusion forever.

A boring structure wins.

Keep one place for hooks. One place for shared UI components. One place for content and metadata. Thin pages that mostly wire things together.

app/
  dashboard/
  solutions/
  foo/
  bar/
components/
hooks/
lib/

This does not make the app faster at runtime. It makes humans faster. That compounds.

DRY is about removing decisions, not saving lines

Duplicated logic forces the reader to make choices.

Which metadata file is correct. Which SEO description was updated. Which API client variation should I use.

Centralize anything that changes together.

export function buildMetadata(input: MetadataInput): Metadata {
  return {
    title: input.title,
    description: input.description,
    openGraph: { ... },
    twitter: { ... },
  };
}

When every page uses the same builder, you stop worrying about consistency. You trust the system again.

Trust is performance.

Prefer server components until proven otherwise

Client components are easy. They are also expensive.

Every client component ships JavaScript, runs hydration, and increases build and bundle cost. Use them only when you actually need interactivity.

If a component does not use state, effects, or browser APIs, keep it on the server.

export default async function Page() {
  const data = await getData();
  return ;
}

No use client. No hydration. Less work.

You can always move logic client side later. Moving it back is harder.

Cache aggressively, but intentionally

Next gives you caching for free, but only if you let it.

Use fetch with caching semantics instead of rolling your own.

await fetch(url, { next: { revalidate: 60 } });

Avoid wrapping everything in dynamic just because something broke once. That turns your app into an expensive SPA with extra steps.

If something must be dynamic, isolate it. Do not poison the whole page.

Keep API clients dumb

Your API client should not know about routing, auth state, or UI behavior. It should send requests and return data.

Once we added a server proxy, the client became simpler instead of more complex.

await fetch('/api/backend/payments', {
  credentials: 'include',
});

No tokens. No headers. No branching logic.

The server handles auth. The client just asks for data.

This improves security and reduces mental load.

Never store tokens in localStorage

It is convenient. It is also unsafe.

LocalStorage is readable by any injected script. There is no mitigation once it is compromised.

If you care about security, use httpOnly cookies and move auth to the server.

cookies().set('access_token', token, {
  httpOnly: true,
  secure: true,
});

Yes, it requires more setup. Yes, it is worth it.

Reduce build work by deleting code

Most build optimizations come from removing work, not adding tools.

Things that slow builds quietly: Duplicated imports Large client components Overused dynamic rendering Unused dependencies Copy pasted config

Before reaching for caching layers, ask what the compiler is doing repeatedly that it should not be doing at all.

Treat DX issues as real bugs

If a fresh clone does not boot cleanly, that is a bug. If formatting changes every commit, that is a bug. If Node versions drift, that is a bug.

Pin your environment.

# .nvmrc
20

Commit generated files that tools expect. Pick one package manager. Make the happy path obvious.

The goal is that nothing surprising happens on day one.

Make failures explicit

If the backend is down, fail clearly. If an image format is unsafe, opt into it intentionally. If something is deprecated, address it early.

Silent failures turn into folklore. Loud failures turn into fixes.

The real optimization

A fast Next.js app is not one with the most tricks. It is one that does the least unnecessary work. For the browser. For the server. For the developer.

When the structure is clear, the build gets faster. When the security model is clean, the code gets simpler. When the project explains itself, people stop being afraid to change it.

That is when Next.js starts to feel boring again.

And boring is exactly what you want.

Update History

Feb 3, 2026The real optimization
Feb 3, 2026Make failures explicit
Feb 3, 2026Treat DX issues as real bugs
Feb 3, 2026Reduce build work by deleting code

Share this writing