For most of the last four years, "fast" meant one thing to me: get the bundle smaller. Code-split harder. Lazy-load the modal nobody opens on first visit. Tree-shake whatever lodash function someone imported for one `.get()` call. It worked, mostly, and it made me feel like I understood performance.
Then we rebuilt an admin dashboard for a client on the Next.js 15 App Router, and the initial JS payload for the main table view came out to zero kilobytes. Not "small." Zero. And I had to sit with that for a minute, because my entire performance vocabulary was built around minimizing a number that had just stopped existing for that route.
What was actually going on
Server Components aren't a bundling trick. They're a different question entirely. Instead of "how do I ship less JavaScript," the question becomes "does this component need JavaScript at all." A table that renders 200 rows of account data, does some formatting, and sorts server-side before it ever reaches the browser doesn't need React running client-side to exist. It needs HTML. The browser just paints it.
Where it gets interesting — and where I've watched three different engineers on our team get it wrong before it clicked — is the boundary problem. The moment you need `onClick`, `useState`, or anything that reacts to a user's mouse, you've crossed into Client Component territory, and everything below that boundary in the tree ships JS again. So the actual skill isn't "use Server Components." It's figuring out where the boundary should sit.
On that dashboard, the mistake we almost made was wrapping the whole data table in a client component because one column had an inline "mark as reviewed" toggle. Do that and you've client-shipped 200 rows of rendering logic to support one button. What we did instead: server-render the table itself, and drop a tiny client island — just the toggle button, nothing else — into each row. The table stays server-rendered. Only the interactive sliver ships JS.
The part nobody mentions in the docs
Data fetching waterfalls get worse before they get better with this model, not automatically better. If you're not careful, a server component tree with three levels of nested `await fetch()` calls will serialize sequentially on the server the same way client-side waterfalls used to serialize in the browser — except now you can't see it in the network tab, because it's happening server-side before any HTML goes out. We didn't catch this until we put actual server-side timing logs in and saw a 340ms "hang" before first byte on one route. The fix was parallelizing the fetches with `Promise.all` at the layout level instead of letting each nested component fetch independently — obvious in hindsight, invisible until you go looking for it.
Where I've landed on this
I'm not going to tell you Server Components make everything faster by default, because they don't — they make it *possible* to ship less, and then it's on you to actually draw the boundaries correctly. A badly-boundaried Server Component app can still ship a bloated client bundle; it just hides the bloat in a different place than it used to.
What changed for me isn't a number. It's that "fast" used to mean a smaller version of the same thing. Now it sometimes means the thing doesn't need to exist client-side at all, and the interesting engineering work is figuring out which parts of a page actually need to.
If you're evaluating a rebuild and someone tells you "we'll just move it to Next.js and it'll be faster," ask them where they're planning to draw the server/client boundary. If they don't have an answer yet, that's fine — most people don't until they've drawn it wrong once.
Execute This Architecture With Solvantis
Ready to put these engineering patterns into production? Explore our custom application packages or view live production case studies.

