Next.js App Router: Patterns That Worked
Server Components, streaming, and caching boundaries — a pragmatic guide to shipping fast App Router apps without fighting the framework.
I was resistant to the App Router for a long time. The Pages Router worked, I knew where the edges were, and every month there seemed to be a subtle caching change that broke something in production. We moved anyway, and after a year of shipping on it, I can say the things I was worried about were real, and the things I got wrong were different ones.
Push 'use client' down the tree
This is the advice you'll read everywhere and it's right, but people don't push it far enough. Our first version had a client boundary at the page level — essentially the old Pages Router behavior. It worked, but we shipped hundreds of kilobytes of JS we didn't need. The rewrite took an afternoon: a server page, a server header, a small client island for the theme toggle, another for a form. Bundle dropped by about 60%. Nothing else changed.
Streaming is less magic than it looks
Suspense plus async Server Components feels like magic until you realize it's basically chunked HTML. The trick is picking Suspense boundaries where the user won't mind a skeleton. Wrap the whole page and you've got a loading screen. Wrap one slow widget and you've got a live page with a placeholder in the corner. That's the one you want.
// Page loads instantly; the dashboard streams in.
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>Be loud about caching
The fetch caching defaults have shifted enough between Next versions that I just stopped relying on them. Every fetch in our codebase spells out what it wants, even when the default would match. It's one extra line and it survives upgrades.
The other rule I won't bend on: nothing user-specific gets cached at the CDN. I've debugged the wrong-user-data bug twice in my career and neither time was fun. Tag it, revalidate on mutation, move on.
A short checklist before I ship
- Is every client component as low in the tree as it can go?
- Does every async boundary have a Suspense fallback that doesn't look broken?
- Does every fetch declare its cache behavior out loud?
- Do loading.tsx and error.tsx exist for this route group, and do they actually make sense when you hit them?
That's it, honestly. The framework will try to help you in a lot of clever ways, but clever is the enemy of predictable. The teams I've seen ship fastest on the App Router are the ones that kept their pages boring.