Skip to content
Development10 min read

Next.js App Router: Practices That Hold Up in Production

Server components, caching, data fetching, and the four mistakes we've made on real App Router projects so you don't have to repeat them.

Bilal Rehman

Lead Developer, Quesiono

We've shipped a dozen App Router projects now, from marketing sites to a reporting dashboard serving 500 daily users. This is what we've settled on, including the parts we got wrong first.

Keep components on the server until something forces otherwise

The default should be a server component. Add "use client" when you need state, an effect, a browser API, or an event handler — not before.

The mistake we made early was putting "use client" at the top of a page because one child needed interactivity. That pulls the whole subtree into the client bundle. The fix is to push the boundary as far down as it goes: keep the page and its layout on the server, and make the interactive leaf its own client component.

A concrete example. A listing page with 40 property cards and a filter bar. Wrong version: the page is a client component, so all 40 cards ship as client JavaScript. Right version: the page and cards are server components, and the filter bar is one client component that receives the dataset as a prop. Same behaviour, a fraction of the bundle.

Understand the four caches before you fight them

App Router caching confuses people because there are four distinct layers and the error messages don't tell you which one you're hitting:

  • Request memoization — identical fetches within one render pass are deduplicated automatically. This is why you can call the same data function in a layout and a page without doubling requests.
  • Data cache — persists fetch results across requests and deployments. Controlled per fetch with next: { revalidate } or cache.
  • Full route cache — the rendered HTML and payload for static routes, built at build time.
  • Router cache — client-side, in memory, holds visited route payloads for the session.

The practical rule: be explicit. Set revalidation on every fetch rather than relying on defaults, because the defaults have changed between versions and will change again. When something serves stale data, work out which layer is holding it before reaching for revalidatePath everywhere.

For content-driven pages we use time-based revalidation with a webhook-triggered revalidateTag from the CMS. Editors publish, the tag invalidates, the next request rebuilds. No full deploy.

Fetch where you render, not at the top

Coming from the Pages Router the instinct is to gather all data at the page level and pass it down. In App Router that's usually worse.

Fetch inside the component that needs the data. Request memoization prevents duplicate work, components become self-contained, and you can wrap each one in its own Suspense boundary so a slow section doesn't hold up the fast ones.

The counter-case: when two sibling components need related data and you want a single query rather than two, hoist it. Don't be dogmatic about it.

Do watch for the sequential waterfall. Awaiting one fetch and then another in the same component serialises them. If they're independent, Promise.all them.

Use Suspense boundaries where the wait is real

Streaming is the App Router's best feature and it's easy to under-use. Wrap slow sections in Suspense with a skeleton that matches the final layout's dimensions — matching the dimensions matters, because a skeleton that's the wrong height causes the layout shift you were trying to avoid.

On the dashboard we built, the shell and navigation render immediately while fifteen chart panels stream in. Time to first paint is under a second even when the slowest query takes three.

Don't wrap everything. A Suspense boundary around something that resolves in 20ms adds a flash of skeleton for no reason.

Mistake one: server actions for everything

Server actions are excellent for mutations tied to a form. We started using them for data fetching too, because it felt tidy to have one mechanism.

They're not designed for that. Server actions run sequentially, they don't benefit from the data cache, and debugging a failed action is worse than debugging a route handler. We now use them for mutations and Route Handlers or direct server-component fetches for reads.

Mistake two: assuming dynamic APIs are free

Reading cookies(), headers(), or searchParams opts the entire route into dynamic rendering. We had a marketing page drop out of static generation because a shared analytics helper read a header, and nobody noticed until build output showed it as a lambda.

Check the build output. Next prints a symbol next to each route showing static, dynamic, or ISR. If a page you expected to be static is dynamic, something is reading a request-time API — trace it before shipping.

Mistake three: parallel routes for a modal

Parallel and intercepting routes are genuinely clever and we reached for them to build a photo lightbox with a shareable URL. It worked. It also took two days, produced a route structure nobody else on the team could follow, and broke in a way that took another half-day to diagnose after a minor version bump.

We replaced it with a query parameter and a client component in about an hour. The lesson isn't that the feature is bad — it's that a clever routing solution needs to earn its complexity against something simpler.

Mistake four: not typing the data layer

Our first App Router project fetched from a CMS and typed responses as any at the boundary, planning to tighten it later. Later arrived as a runtime error in production when a field was renamed.

Now every external response goes through a Zod schema at the boundary. It costs an hour per integration and catches the class of bug where the API changed and TypeScript happily compiled anyway.

Images and fonts: use the built-ins properly

The Image component handles format negotiation, sizing, and lazy loading, but only if you feed it correctly. Always pass width and height (or fill with a sized parent), always set sizes when using fill, and set priority on the LCP image.

On next.config: if you don't serve remote images, don't configure remote patterns. A wildcard hostname combined with dangerouslyAllowSVG lets any origin push a script-bearing SVG through your optimiser. We found exactly that configuration on a site we inherited — including our own, in an early version of this one.

For fonts, next/font self-hosts and generates a metric-matched fallback, which removes both the third-party request and most of the font-swap layout shift. Load the weights you use and no more.

Read the build output every time

Two minutes after every build:

  • Which routes are static, dynamic, or ISR — and is that what you intended?
  • First-load JS per route. Above 200KB on a content page means something heavy got pulled in.
  • New warnings. They're usually the deprecation you'll trip over in three months.

Most of the performance problems we've caught before launch came from reading that output rather than from profiling.

If you're planning a Next.js build and want a sanity check on the architecture before committing, our web development engagements start with exactly that conversation.

Want us to do it

The services behind this article

If you'd rather not run this yourself, these are the pages that cover it.

Got a project

Prefer to hand this to someone else?

Tell us what the site needs to do. You'll get a straight answer on scope, timeline and cost — usually inside one working day.

Rather write first?hello@quesiono.com— we reply within one business day.