Skip to content
SJ
All writing
11 min read

Choosing a Rendering Strategy in the App Router

The client boundary is a bundling decision, not a syntax detail. One "use client" at the wrong level quietly ships an entire import tree to the browser.

Next.jsReactPerformanceFrontend

The App Router asks two questions that the Pages Router mostly answered for you: when should this route be rendered, and what part of it needs to run in the browser. They are separate questions, they are answered per route rather than per application, and getting the second one wrong is invisible until you look at a bundle.

Four strategies, chosen per route

Static. Rendered at build time, served as a file. The fastest thing possible and the correct default for anything whose content does not depend on the request: marketing pages, documentation, blog posts. If the same HTML is right for everyone, it should be built once.

Dynamic. Rendered per request. Necessary when the output depends on who is asking — a dashboard, anything behind auth, anything reading cookies or headers. Note that a route becomes dynamic the moment you touch a request-scoped API, whether or not you intended it.

Incremental regeneration. Static, with a revalidation window. Users are served a cached page instantly while a fresh one is generated behind them. This is the right answer for content that changes occasionally and is read constantly — a product catalogue, a CMS-backed site, a listing page. It is also the most underused of the four, because people assume “the data changes” means “must be dynamic,” when what they actually need is data that is at most a minute old.

Streaming. Not an alternative to the others but a modifier: send the shell immediately and stream slower sections in as they resolve. It converts one slow query from a blank page into a page with a loading region.

The practical approach is to keep every route static until something forces it to be otherwise, and to know what forces it. A single unnecessary cookies() call in a shared layout makes every route beneath it dynamic, which is a common way to lose static rendering across an entire section without noticing.

The client boundary is a bundle decision

This is the part that catches people, and it is worth being precise about.

Server components run only on the server. Their code is never sent to the browser. They can read the database directly, use secrets, and import heavy libraries at zero cost to the client.

"use client" marks a boundary. The component and everything it imports gets bundled and shipped. Not just the component — the whole import tree beneath it.

So this file is expensive in a way that does not look expensive:

"use client";
import { useState } from "react";
import { formatDistance } from "date-fns";
import { ENORMOUS_LOOKUP_TABLE } from "@/data/reference";
import PostBody from "@/content/posts/some-long-article";

export default function Widget() {
  const [open, setOpen] = useState(false);
  // ...
}

One useState just pulled a date library, a lookup table and an entire article into the client bundle. The component needed to be interactive; its imports did not need to come along.

I hit exactly this building the writing section of this site. The section needed a scroll-reveal animation, so it was marked "use client" — and because it imported the post registry to render cards, every post body became part of the homepage bundle. The content was static text that had no business being JavaScript.

Composing across the boundary

The fix is not to avoid client components. It is to make the client component small and pass server-rendered content through it as children.

// Reveal.tsx — client, and tiny
"use client";
export default function Reveal({ children }: { children: React.ReactNode }) {
  const ref = useScrollReveal();
  return <section ref={ref} className="reveal">{children}</section>;
}

// Section.tsx — stays a server component
export default function Section() {
  return (
    <Reveal>
      <ExpensiveServerRenderedContent />   {/* never reaches the browser */}
    </Reveal>
  );
}

Children passed into a client component are rendered on the server and sent as serialised output. The client component receives them already rendered — it never imports them, so it never bundles them. That single pattern resolves most “how do I use a hook without going all client” problems.

The rule of thumb: push the boundary as far down the tree as it will go. A page is not interactive. A layout is not interactive. The button is interactive. Mark the button.

Streaming, and where suspense boundaries belong

A page that awaits three queries before rendering anything is as slow as the slowest one. Streaming lets the shell go out immediately.

The judgement is where to put the boundaries. Wrap the whole page and you have reinvented a full-page spinner. Wrap every element and the page assembles itself in a visually unpleasant cascade. The useful split is along data dependency and importance: render the primary content as soon as its own query resolves, and stream the secondary regions — recommendations, activity feeds, anything below the fold — separately.

Two practical notes. A loading.tsx file gives you a boundary for the whole route segment for free, which is the right starting point. And skeletons should match the shape of what replaces them, or you have traded a spinner for a layout shift, which is worse.

Caching, deliberately

The caching behaviour has changed across versions enough that cargo-culted advice is unreliable. What is stable is the mental model: there is a request-level cache for deduplication within a single render, and a persistent data cache across requests, and you should know which one you are relying on for each fetch.

Rather than memorising defaults, be explicit. State the revalidation window where the data is fetched, tag the fetches that share a lifecycle, and invalidate by tag when you mutate. Explicit configuration survives a framework upgrade; relying on an implicit default does not.

The failure worth watching for is the opposite of the usual one: not stale data, but a route that quietly stopped being cached because something in its tree became dynamic. That does not error. It just gets slower and more expensive, and the only way to notice is to look.

Measuring what actually shipped

All of the above is guesswork until you look at the output. Three checks, in order of how often they surprise people:

  • The build output. Every route is listed with its rendering strategy and its First Load JS. A route you believed was static and is marked dynamic is a bug you can see in one line.
  • The bundle analyser. Sorted by size, the top entries are almost always something that crossed the client boundary by accident.
  • The rendered HTML. View source on a page you expect to be server-rendered. If the content is not there, it is not.

The number to watch is First Load JS per route, and the useful habit is checking it when it changes rather than admiring it once. A route that grows by 40kB in a pull request is a question worth asking in review, and it is the kind of regression that never announces itself otherwise.

The short version

Pick the strategy per route and default to static until something forces otherwise — then know what forced it. Treat "use client" as a bundling decision, keep the boundary as low in the tree as possible, and pass server-rendered children through client components instead of importing them. Put suspense boundaries where the data is slow, not around everything. Be explicit about revalidation rather than trusting a default. Then read the build output, because it tells you which of your beliefs are wrong.

Written by Saumya Jain

Full Stack Engineer working on headless commerce, NestJS microservices, and real-time systems. Currently open to remote work.