The Two Worlds of NextJS: Server Components vs Client Components

In my previous article, I made the case that NextJS is fundamentally just React running on the server. If you know React, you know 90% of NextJS already. I still stand behind that statement. But after spending more time with NextJS in production, I want to go deeper into the one thing that trips up most developers coming from traditional client-side React: the split between Server Components and Client Components.

This is where NextJS stops being “just React” and becomes something genuinely different. And honestly, it took me a while to fully internalize it.

The Default Is the Server

Here’s the first thing that confuses people coming from classic React: in NextJS with the App Router, every component is a Server Component by default. Not a Client Component. The server.

This is the opposite of what most React developers have been doing for years. In traditional React (Create React App, Vite, whatever), everything runs in the browser. Every component, every hook, every state update. That’s all you’ve ever known.

In NextJS, when you write this:

This component never executes in the browser. It runs on the server, queries the database directly, renders HTML, and sends the result to the client. The browser receives finished HTML. No JavaScript bundle for this component is shipped to the client at all.

Read that again. No JavaScript for this component goes to the browser. That’s a massive difference from how traditional React works.

When You Need the Browser

Obviously, not everything can run on the server. If you need:

  • useState or useReducer for local state
  • useEffect for side effects
  • Event handlers like onClick, onChange
  • Browser APIs like window, localStorage, navigator
  • Third-party libraries that use any of the above

Then you need a Client Component. You opt into it explicitly with the "use client" directive at the top of the file:

This component gets shipped to the browser as JavaScript, hydrated, and runs interactively just like any traditional React component. This is the React you already know.

The Mental Model That Clicked for Me

When I first started, I kept asking myself: “Should this be a server component or a client component?” And I was overthinking it. The mental model that finally clicked was this:

Server Components are for reading. Client Components are for interacting.

If a component’s job is to fetch data and display it — server. If a component needs to respond to user input — client. That’s the 80/20 rule right there.

Think of it like a newspaper. The articles are printed at the factory (server-rendered). But the crossword puzzle needs a pencil in the reader’s hand (client-side interactivity). You wouldn’t ship a printing press to every reader’s house just so they can read the articles. That would be absurd. But that’s exactly what traditional client-side React does — it ships the entire rendering engine to the browser and renders everything there.

The Boundary Between Worlds

Here’s where it gets interesting. Server Components and Client Components can coexist on the same page. In fact, they’re designed to nest together. But there’s one critical rule:

A Server Component can render a Client Component. A Client Component cannot import a Server Component.

This means the boundary flows in one direction: from server to client. You structure your page like this:

Here, ProductDetails can be a Server Component that just displays data. AddToCartButton is a Client Component because it needs onClick and useState. The page itself is a Server Component that orchestrates both.

The data flows down as props. The server fetches the product, renders ProductDetails to HTML on the server, and passes productId as a serializable prop to the Client Component. The browser only receives the JavaScript for AddToCartButton, not for the entire page.

The Real-World Payoff

On the production project I mentioned in my previous article, this architecture made a tangible difference. The dashboard had dozens of data tables, charts, and summary cards. In a traditional React SPA, all of that JavaScript would ship to the browser — the chart library, the table rendering logic, the data transformation functions, everything.

With NextJS Server Components, the heavy data processing stayed on the server. The only JavaScript sent to the client was for the interactive bits: filter dropdowns, sort buttons, a search input, some modal dialogs. The page felt noticeably faster on initial load, especially on mobile connections.

And the bundle size dropped significantly. We didn’t have to be clever about code splitting or lazy loading for the data-display components. They simply weren’t in the bundle at all. They didn’t exist in the browser. Problem solved at the architectural level instead of through optimization tricks.

Common Mistakes I Made (So You Don’t Have To)

Mistake 1: Putting “use client” everywhere.

When something didn’t work, my first instinct was to slap "use client" on the file. This works, but it defeats the purpose. If every component is a Client Component, you’ve essentially recreated a traditional React SPA with extra steps. You lose all the benefits of server rendering.

The fix: push "use client" as far down the component tree as possible. Keep your page-level and layout-level components as Server Components. Only mark the specific interactive leaf components as client.

Mistake 2: Trying to use hooks in Server Components.

This one bit me early. You can’t use useState, useEffect, or any React hook in a Server Component. They don’t exist on the server. There’s no component lifecycle on the server — the component renders once and that’s it. If you need state, you need a Client Component.

Mistake 3: Forgetting that Server Components can be async.

In traditional React, components are synchronous functions (or they use hooks to manage async data). In NextJS Server Components, you can use async/await directly in the component body. This is one of the biggest wins and I kept forgetting it was available:

Try doing that in traditional React. You’d need useEffect, useState for loading state, error handling, probably TanStack Query or SWR. Here it’s just… a function that fetches data and returns JSX.

Mistake 4: Passing non-serializable props across the boundary.

When a Server Component passes props to a Client Component, those props must be serializable — think JSON. You can’t pass functions, class instances, or Dates directly. This caught me off guard when I tried to pass a callback function from a server-rendered parent to a client child. It doesn’t work. Functions can’t cross the server-client boundary.

The solution is usually Server Actions or restructuring the component hierarchy so the function lives inside the Client Component where it belongs.

Server Actions: Closing the Loop

There’s one more piece that makes this architecture complete. Server Actions let Client Components call server-side functions without building an API:

The Client Component calls createOrder as if it were a local function, but it actually executes on the server. Under the hood, NextJS generates an API endpoint for it. The database call, the authentication check, the revalidation — all server-side. The client just triggers it.

This is the complete loop: Server Components render data, Client Components handle interaction, and Server Actions process mutations. No REST API. No GraphQL schema. No BFF layer. The framework handles the plumbing.

How This Compares to the Old World

Let me put this in perspective for anyone who’s been writing React SPAs for years, like I was.

In the old world, a typical data-driven page involved:

  1. A React component with useEffect to fetch data on mount
  2. useState for loading, error, and data states
  3. A backend API endpoint to serve the data
  4. A fetch call from client to server
  5. Loading spinners while waiting
  6. Error boundaries for failure cases

In the NextJS Server Component world:

  1. An async function that fetches data and returns JSX

That’s not an exaggeration. The six steps collapsed into one. The complexity didn’t disappear — it moved into the framework. And I’m fine with that trade-off. The framework is well-tested and maintained by a large team. My custom loading-state-error-handling code was not.

Is It Perfect?

No. There are real rough edges.

The mental overhead of tracking which component runs where is non-trivial, especially in large codebases. When a new developer joins the team, the server/client boundary is one more concept they need to internalize before being productive.

Debugging is harder because errors can originate on the server or the client, and the stack traces don’t always make it obvious which world you’re in.

And some third-party libraries aren’t ready for this split. If a component library uses useEffect internally, you can’t use it in a Server Component. You have to wrap it in a Client Component. This gets tedious.

But these are growing pains, not fundamental flaws. The architecture is sound. The benefits are real. And the ecosystem is catching up.

My Recommendation

If you’re starting a new React project today, use NextJS with the App Router. Default to Server Components. Only add "use client" when you genuinely need interactivity. Push that directive as deep into the tree as you can.

If you’re maintaining an existing React SPA and considering a migration — don’t rush it. The mental model shift is bigger than the syntax change. Take time to understand the boundary between server and client. Build a few pages with the new approach before committing to a full rewrite.

And if you’ve been avoiding NextJS because you thought it was too different from React — go back and read my previous article. It’s still React. The core hasn’t changed. The only new question is: does this component need a browser, or doesn’t it?

Once you internalize that question, the rest follows naturally.

Okay, enough for now. I’ll be back!