What you'll learn
By the end of this you'll have a clear mental model of what React Server Components (RSC) actually are, why they exist, and how to work with them without constantly hitting the "you can't do that here" wall. You'll understand where to put the "use client" directive and what actually happens when you do, how data fetching works in a component tree that partly lives on the server, and why RSC is genuinely good for performance — not just a marketing claim.
The topic deserves proper treatment — get the mental model wrong early and you end up with bugs that are hard to search for.
Who this is for
- You're working with the Next.js App Router and you keep hitting errors about hooks not being available, or you're wondering why
asyncworks in some components but throws in others - You've read "React Server Components" in the docs but it still feels abstract
- You want the actual rules, not just "server components are good for performance"
You can skip this if you're already shipping RSC-heavy App Router apps and you're here to look up a specific pattern. The [React docs on Server Components](https://react.dev/reference/rsc/server-components) are more concise for quick lookups.
What is a React Server Component?
A React Server Component is a component that renders entirely on the server and never ships its own code to the browser. It can await data directly, access databases, read files, and use server-only secrets — because it never runs in a browser.
Plain English: it's a React component that lives and dies on the server. The browser gets the rendered HTML output and a lightweight description of the UI. The component's own code — its imports, logic, database calls — never touches the client bundle.
Simple idea: think of it as a PHP template that writes JSX. It runs once per request on the server. No state, no event handlers, no browser APIs. Just data in, UI out.
Prerequisites
- You know how React components work — props, JSX, component trees
- You've used the Next.js App Router a bit: layouts, pages, at least one
fetchin a page file - You've seen
"use client"somewhere, even if it confused you
Key terms
Server Component — a React component that renders on the server. In the App Router, every component is a Server Component by default unless marked otherwise.
Client Component — a component that can use state, effects, event handlers, and browser APIs. Marked with "use client" at the top of the file. (It still server-renders on first paint — "Client Component" means "can also run in the browser," not "only runs in the browser.")
use client directive — the string literal "use client" at the very top of a file. It marks that file and everything imported by it as part of the client bundle.
Serializable props — data that can be converted to JSON and sent over the wire from server to client. Strings, numbers, arrays, plain objects. Not functions, not class instances, not Date objects (without conversion).
Bundle boundary — the line "use client" draws. Code on the server side of the line never ships to the browser; code on the client side does.
server-only — a tiny npm package that throws a build error if you accidentally import a server-only module (DB clients, secret-reading utilities) into a client bundle. One import, zero config.
Setup from zero
Step 1 — Notice you already have Server Components
In a fresh Next.js App Router project, every page.tsx and layout.tsx is a Server Component by default. You don't add anything to opt in. Open any page.tsx and write an async function — it works.
Step 2 — Fetch data directly in the component
// app/blog/page.tsx — Server Component, no "use client"
export default async function BlogPage() {
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 3600 },
});
const posts = await res.json();
return (
<ul>
{posts.map((p: { id: number; title: string }) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
No useEffect. No loading state managing. The data is fetched and the HTML is ready before anything reaches the browser.
Step 3 — Add a Client Component where you need interactivity
// components/like-button.tsx
"use client";
import { useState } from "react";
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? "Liked" : "Like"}
</button>
);
}
Import LikeButton into a Server Component and pass postId as a prop. That's the composition pattern in its simplest form.
The mental model
Think of your component tree as an iceberg. Server Components are the underwater part — the bulk of the logic, data access, and layout. Client Components are the tip — just the interactive pieces. And "use client" draws the waterline. Everything in that file and everything it imports goes into the client bundle. Put the line as far down the tree as you can.
Step-by-step
The composition pattern
You can pass a Server Component's output to a Client Component as children. This is the key technique for keeping interactive wrappers thin:
// components/collapsible.tsx
"use client";
import { useState } from "react";
export function Collapsible({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return (
<div>
<button onClick={() => setOpen(!open)}>
{open ? "Hide" : "Show"}
</button>
{open && <div>{children}</div>}
</div>
);
}
// app/sidebar/page.tsx — Server Component
import { Collapsible } from "@/components/collapsible";
import { DataList } from "@/components/data-list"; // also a Server Component
export default function SidebarPage() {
return (
<Collapsible>
<DataList /> {/* renders on server, output passed as children */}
</Collapsible>
);
}
DataList runs on the server and can access databases freely. Collapsible handles the click. Neither knows much about the other's internals.
What you can't do in a Server Component
- React hooks (
useState,useReducer,useEffect, etc.) — they depend on a client runtime that Server Components don't have - Event handlers (
onClick,onChange,onSubmit) - Browser APIs (
window,document,localStorage) - Libraries that use any of the above internally
If you try, you'll see "useState can only be used in a Client Component." The fix: add "use client" to that file, or extract the stateful bit into a smaller leaf component.
Working examples
Database access in a Server Component
// app/posts/[slug]/page.tsx
import { db } from "@/lib/db"; // your database client — server only
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await db.posts.findOne({ slug });
if (!post) return <p>Not found</p>;
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</article>
);
}
The database client import never reaches the browser. If you tried the same thing in a Client Component, you'd need an API route — or you'd accidentally ship your DB credentials to the world. (Yes, that has happened to real projects.)
Mixing server data with client interactivity
// app/posts/[slug]/page.tsx
import { db } from "@/lib/db";
import { LikeButton } from "@/components/like-button"; // "use client"
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await db.posts.findOne({ slug });
return (
<article>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
<LikeButton postId={post.id} initialCount={post.likeCount} />
</article>
);
}
Little tip: pass primitive props (postId as a string, initialCount as a number) rather than the whole database object. Serialisation is cheaper, the Client Component doesn't get fields it won't use, and the types stay clean.
Protecting server-only modules
// lib/db.ts
import "server-only"; // throws at build time if imported by a client bundle
import { MongoClient } from "mongodb";
// ...
Little tip: add import "server-only" to every file that touches secrets, DB clients, or environment variables you want off the client. The error message is clear and catches mistakes before they reach production.
Patterns / when to use
Use a Server Component when — you're fetching data, reading from a database, doing heavy computation, accessing secrets, or building layout and page shells. The default.
Use a Client Component when — you need useState, event handlers, animations, browser APIs, or third-party libraries that use any of those things internally (most charting libraries, drag-and-drop, date pickers).
The practical rule: start every component as a Server Component. Add "use client" only when you need something from the list above.
One more pattern worth naming — leaf components. Put "use client" on small leaves (a button, a dropdown) rather than large containers. If you put it on a top-level layout, you pull the entire subtree into the client bundle.
Common mistakes
Adding "use client" to everything "just to be safe" — this pushes your whole app into the client bundle. You lose server-side data fetching and direct DB access. Be deliberate about where the boundary goes.
Passing functions as props from Server to Client — functions aren't serialisable. You'll get a runtime error. Define the handler inside the Client Component, or use a Server Action.
Importing server-only modules into Client Components — if lib/db.ts uses Node.js internals or reads secrets, importing it in a "use client" file fails at build time. Add import "server-only" to those files for a clear early error instead of a cryptic one.
Assuming Client Components skip server rendering — they don't. "use client" means the code also runs in the browser for hydration. Don't reference window at module scope — use useEffect or check typeof window !== "undefined".
Troubleshooting
"You're importing a component that needs useState" — that component needs "use client". Add it to the top of its file.
"Functions cannot be passed directly to Client Components" — you're passing a function prop across the server/client boundary. Move the function definition inside the Client Component, or convert it to a Server Action with "use server".
Build error about window or document not defined — a library uses browser globals at module scope. Use next/dynamic with { ssr: false }, or move the import inside useEffect.
Stale data after a mutation — after a Server Action runs, call revalidatePath or revalidateTag to bust the cache and fetch fresh data on the next render.
Checklist
- [ ] Page and layout files are Server Components (no
"use client"at the top) - [ ] Data fetching happens in Server Components, not in
useEffect - [ ]
"use client"is placed on leaf components, not root layouts - [ ] No hooks (
useState,useEffect, etc.) in Server Component files - [ ] Server-only imports (DB clients, secrets) are not used in any
"use client"file - [ ] Props crossing the boundary are serialisable (strings, numbers, plain objects)
- [ ]
server-onlypackage imported in files that touch secrets or DB clients - [ ]
revalidatePathorrevalidateTagcalled after mutations that change server data
Practice task
Fetch a list of users from https://jsonplaceholder.typicode.com/users in a Server Component — no useEffect, just async/await. Build a Collapsible Client Component that toggles the visibility of children. Wrap the user list in the collapsible by passing it as children from the Server Component. Check DevTools Network — there should be no client-side request to JSONPlaceholder.
FAQ
Do Server Components replace Redux or Zustand?
For server data, largely yes — fetch directly in the component and pass it down. For client-side UI state (isOpen, form state), those libraries still make sense in Client Components.
Can I use async/await in Client Components?
Client Components can't be async functions. Use useEffect + useState, SWR, or React Query for client-side data fetching.
What about third-party UI libraries?
Most popular libraries (shadcn/ui, Radix UI) are designed for Client Components. Import them in "use client" files and they work fine.
Do I need "use server" anywhere?
Only for Server Actions — functions called from Client Components that run on the server. Regular Server Component files need no directive at all.
What to learn next
- Server Actions — the mutation half of the story; call server-side functions directly from Client Components without writing an API route
- Streaming and Suspense — defer slow Server Components so they don't block the rest of the page
- Route handlers — when you need an actual HTTP endpoint for webhooks or external clients
Related on Baseline
- [Next.js route handlers](/developers/nextjs/nextjs-route-handlers)
- [App Router fundamentals](/developers/nextjs/nextjs-app-router-guide)
- [Next.js metadata and SEO](/developers/nextjs/nextjs-metadata-and-seo)
Takeaways
React Server Components render on the server, never ship their own code to the browser, and can fetch data directly with async/await. Client Components handle interactivity and are marked with "use client". The directive is a bundle boundary — everything below it ships to the browser. Put it as low in the tree as you can.
If you remember only one thing: "use client" isn't a switch that controls where a component renders — it's a line that says "this code goes into the browser bundle." Keep the line low, pass only serialisable props across it, and your app stays fast.