Two years ago we rebuilt our first production app on the Next.js App Router. The promise was tempting: less JavaScript on the client, simpler data fetching, fewer round trips. The reality was messier — but the patterns that survived are now load-bearing in every project we ship.
What stuck: server-first data loading
Fetching data in the component that needs it, on the server, eliminated entire categories of bug. No more useEffect chains. No more loading states that flicker for 80ms before the data resolves. The page either renders with data or doesn't render at all.
The unexpected win was deduplication. Two server components fetching the same URL inside the same request get one fetch, automatically. That alone removed a global cache layer from one of our apps.
What we ripped out: client islands of state
Our first instinct was to keep our old client-state stores (Zustand, Redux) and just move the data layer to the server. This was a mistake. The boundary between server-rendered data and client-mutated state became a constant source of stale UI bugs.
We've settled on a rule: if it's serializable and tied to the URL, the server owns it. If it's ephemeral UI state (open/closed, hover, draft input), the client owns it. We use Server Actions plus revalidatePath for everything in between.
The pattern we'd recommend
- Push fetches as deep into the tree as you can — co-locate data with the component that uses it.
- Use Suspense boundaries to stream the slow parts. Don't block the whole page on the analytics widget.
- Reach for client components only when you need interactivity, browser APIs, or third-party SDKs.
- Treat Server Actions as your API surface — type-safe, no fetch handler boilerplate.
Two years in, the App Router is the default for every new project we start. The migration cost is real, but the maintenance savings compound for the lifetime of the app.