React Server Components confused me longer than most React concepts have, mostly because the mental model, components that render on the server and never ship JavaScript to the client at all, breaks assumptions I'd built up over years of client-side React. Working through a real feature with them in Next.js's App Router is what finally made the boundary between server and client components click.
The single biggest adjustment: in the App Router, every component is a Server Component by default unless you explicitly opt into client rendering with a "use client" directive at the top of the file. I kept instinctively reaching for useState in components that didn't need it, and the compiler error reminding me that hooks aren't available in Server Components became the thing that retrained the habit faster than any documentation did.
// app/products/page.tsx — Server Component, runs on the server, no bundle sent
async function ProductsPage() {
const products = await db.product.findMany();
return ;
}
The part that actually sold me was data fetching. A Server Component can be an async function that awaits a database call or fetch directly in the component body, no useEffect, no loading state boilerplate, no client-side waterfall. The first time I deleted a useEffect-plus-useState data fetching block and replaced it with a single await, the code was shorter and the network tab showed one less round trip.
Anything interactive, onClick handlers, useState, useEffect, browser-only APIs, needs "use client", and the tricky part isn't adding that directive, it's deciding how far down the tree to push it. Marking a whole page as client just because one button needs an onClick handler drags everything else in that subtree into the client bundle too. Isolating the interactive piece into its own small component and keeping everything around it as a Server Component kept my client bundle smaller than my first instinct would have.
Props flow from Server to Client Components, but not the reverse, and only serializable values cross that boundary, no functions, no class instances, no Dates without conversion. I hit this directly trying to pass a database result with a Date field straight into a client component and getting a serialization error that took a confused minute to connect to the actual cause.
A Client Component can still render a Server Component if it receives it as children or another prop rather than importing it directly, which lets you keep a server-rendered piece inside an interactive wrapper. This pattern, wrapping server content in a client-side layout component via children, solved a case where I needed a client-side collapsible panel around content that itself did a server data fetch.
Wrapping a slow-loading Server Component in a Suspense boundary lets the rest of the page render and stream to the browser immediately while that piece loads in, with a fallback shown in its place. Adding these boundaries around anything hitting a slow API turned a page that used to block entirely on the slowest data source into one that felt responsive from the first paint.
None of this was intuitive on day one, and I underestimated how much existing React mental models actively worked against me here. Teammates coming from a pure client-rendered React background hit the same "why can't I use useState here" wall I did, and the error messages, while informative once you understand the model, don't explain the model itself the first time you see them.
Once Server Components handled reads well, Server Actions, functions marked "use server" that can be called directly from a form or client event handler, closed the gap on writes. Submitting a form that calls a Server Action to insert a database row, without hand-writing a corresponding API route and a fetch call to hit it, removed a whole category of glue code that used to exist purely to bridge client and server. The revalidatePath call inside the action, telling Next.js which cached data to refresh afterward, was the piece that took the longest to internalize correctly.
The least pleasant part of the learning curve was debugging a component that silently behaved differently than expected because it was rendering in a context I didn't intend. A browser API call inside a component I assumed was client-side, but which a parent had left as a Server Component, failed with an unhelpful error until I traced the actual render boundary explicitly. Adding "use client" isn't just a fix, it's also useful as a debugging technique, deliberately marking a suspect component client-side temporarily to confirm whether the boundary itself was the problem.
Rather than trusting that the migration was helping, running a bundle analyzer before and after moving a heavy dashboard page's data-fetching logic into Server Components gave an actual number to point to, not just a feeling that things seemed leaner. The client JavaScript shipped for that specific page dropped meaningfully once the fetching and formatting logic that never needed to run in the browser stopped being bundled for the browser at all, which made the migration cost easier to justify to the rest of the team.
Server Components genuinely reduce client bundle size and eliminate a category of data-fetching boilerplate once the server/client boundary clicks, but that click takes real, deliberate practice on an actual feature, not just reading the docs. For a new Next.js App Router project I'd adopt them without hesitation; retrofitting them onto an existing Pages Router app is a bigger, more disruptive decision that deserves its own separate evaluation.