I’ve read about a dozen articles on React Server Components and most of them feel the same. They explain the mental model, draw a diagram, say something about “reducing bundle size,” and move on. Then you open your editor to actually use them and realize nobody told you the stuff that matters.

This is the stuff that matters. The parts of your code that actually change. The weird errors you’ll hit. The things I got wrong for weeks before something clicked.

I’m not going to explain what a Server Component is from first principles. There are enough posts for that. I’m going to talk about what it’s like to write code once you start using them.


The default flips

The biggest mental shift: in a Server Components world, server is the default. Everything is a Server Component unless you say otherwise with "use client".

I know that sounds small. It’s not. Your brain has to rewire. For years I wrote React assuming every component runs in the browser. Now I write components that never ship to the browser at all. They run, render to HTML, and that’s it. No useState, no useEffect, no event handlers.

Here’s a Server Component:

// app/posts/page.tsx
import { db } from "@/lib/db"

export default async function PostsPage() {
  const posts = await db.post.findMany()

  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  )
}

That’s it. An async component that hits the database directly. No API route. No fetch. No useEffect. The first time I wrote this, I deleted it because I thought I was doing something wrong.


Where you actually need “use client”

Here’s where the confusion kicks in. Everyone says “add use client when you need interactivity.” That’s roughly true but it hides the real rule.

You need use client when your component uses:

  • useState, useReducer, or useEffect
  • Event handlers like onClick, onChange, onSubmit
  • Browser-only APIs like window, document, localStorage
  • Context providers you’re wrapping your app in

You don’t need it just because a component is “interactive-looking.” A button that links somewhere isn’t interactive in this sense. A form that posts to a server action isn’t interactive in this sense.

The rule I now follow: write it as a Server Component first. Add use client only when something breaks. I used to do the opposite and ended up with clientside components that didn’t need to be.


The error that confuses everyone

You’ll hit this one:

Error: Functions cannot be passed directly to Client Components
unless you explicitly expose them by marking them with "use server".

This happens when a Server Component tries to pass a function as a prop to a Client Component. It feels like a stupid restriction until you realize why: functions can’t be serialized and sent over the network.

The fix is usually one of three things.

1. Move the function inside the Client Component. If the function only runs in response to a click, it belongs on the client.

2. Mark the function as a server action with "use server". Then it can be passed across the boundary.

// app/actions.ts
"use server"

export async function deletePost(id: string) {
  await db.post.delete({ where: { id } })
}
// app/posts/page.tsx
import { deletePost } from "./actions"
import { DeleteButton } from "./DeleteButton"

export default function PostsPage() {
  return <DeleteButton onDelete={deletePost} />
}

3. Don’t pass the function at all. Sometimes the Client Component can import the server action directly and skip the prop.

I probably wasted three afternoons on this error before the pattern stuck. Just understand that the server/client boundary is a real network boundary, and the errors start making sense.


Data fetching stops being a thing you think about

This is the part I genuinely love. Data fetching in Server Components isn’t fetching. It’s just async/await against your database or API, right in the component.

Before:

function PostsPage() {
  const [posts, setPosts] = useState([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetch("/api/posts")
      .then((r) => r.json())
      .then((data) => {
        setPosts(data)
        setLoading(false)
      })
  }, [])

  if (loading) return <Spinner />
  return <PostList posts={posts} />
}

After:

async function PostsPage() {
  const posts = await db.post.findMany()
  return <PostList posts={posts} />
}

No useEffect. No loading state. No API route. And the HTML arrives with the data already in it, so there’s no flash of empty state either.

I know this looks too simple. That’s kind of the point.


The mistake I made for a month

I kept adding "use client" to components that had a single interactive element. A page with a “Like” button? Entire page became a Client Component. Comments section with a reply form? Whole thing client.

You don’t have to do this. You want to push the client boundary as deep as possible. The page itself stays a Server Component. Only the Like button becomes a Client Component.

// app/post/[id]/page.tsx — Server Component
import { LikeButton } from "./LikeButton"
import { db } from "@/lib/db"

export default async function PostPage({ params }) {
  const post = await db.post.findUnique({ where: { id: params.id } })
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <LikeButton postId={post.id} initialLikes={post.likes} />
    </article>
  )
}
// app/post/[id]/LikeButton.tsx
"use client"
import { useState } from "react"

export function LikeButton({ postId, initialLikes }) {
  const [likes, setLikes] = useState(initialLikes)
  return <button onClick={() => setLikes(likes + 1)}>{likes}</button>
}

Only the button ships to the browser. The rest is pre-rendered HTML. This is the bundle-size improvement everyone talks about, but you only get it if you’re deliberate about where the boundary sits.


Server Components can import Client Components. Not the other way around.

Well, sort of. A Client Component can receive a Server Component as a children prop or other prop, but it can’t import one directly. That’s because the Client Component runs in the browser, and the Server Component can’t run there.

This feels weird at first. In practice it means you structure your app with the Server Components on the outside and the Client Components on the inside. Occasionally you’ll have a Client Component that wraps some Server-Component children — like a Tabs component where the tabs are server-rendered but the tab switcher is client.

// Client Component wrapping Server Component children
<Tabs>
  <TabPanel><ServerRenderedContent /></TabPanel>
</Tabs>

This works because children is passed as a prop, not imported. The Server Component’s output is rendered on the server and handed to the Client Component as already-rendered React.

The shorthand: composition works across the boundary. Imports don’t.


What still confuses me sometimes

Caching. Next.js layers caches on top of Server Components in ways that are mostly helpful and occasionally maddening. If a page isn’t updating when you expect, it’s probably cache. Read the docs on revalidate, cache, and no-store. Don’t guess.

Also: streaming with Suspense. It’s great when it works. It’s confusing when you’re wondering why part of your page renders instantly and another part takes two seconds. The rule is simpler than it looks — anything inside a <Suspense> boundary can render on its own, so the slow bits don’t block the fast bits.


What actually changes in your day-to-day

If I had to sum it up in a paragraph: you write fewer useEffects, fewer loading states, fewer API routes. You think harder about where the client/server line sits. You get errors that seem weird until you realize they’re enforcing a real boundary. And you end up shipping less JavaScript to the browser without really trying.

The adjustment period is annoying. I’ll be honest. For about two weeks everything felt slower because I was re-learning things I already knew. Then it clicked and now writing the old way feels tedious. That’s the arc you should expect.

Don’t learn Server Components from a blog post, including this one. Learn them by writing a real app with them. The docs can only take you so far. The muscle memory is the thing.