After running RSC in production with the Next.js App Router, the question that comes back most in conversations with other teams is about the border between Server and Client Component. Below is the decision map I use, plus the mistakes I have watched teams make during the transition.
A Server Component renders on the server, queries the database, filesystem, or an internal API, produces HTML plus RSC payload, and ships no JavaScript to the client. A Client Component enters the client bundle, runs in the browser (and also on the server, during hydration), and reaches useState, useEffect, event handlers, and browser APIs.
The practical consequence: logic, heavy parsers, and database calls inside a Server Component cost zero bytes on the client. The React Server Components manual and the use client page define that border.
Use Server Component when
- The component queries the database, an internal API, or the filesystem.
- It pulls a heavy dependency with no interactivity (
marked,sharp, a PDF parser). - It reads an environment variable that must not reach the client.
- It composes the page and holds no state of its own.
// Server Component: direct query, no client JavaScript
async function ProductPage({ id }: { id: string }) {
const product = await db.product.findUnique({ where: { id } })
return (
<div>
<h1>{product.name}</h1>
<ProductActions product={product} />
</div>
)
}
Use Client Component when
- Local state:
useState,useReducer. - Effects:
useEffect,useLayoutEffect. - Event handlers:
onClick,onChange. - Browser APIs:
localStorage,window,navigator.
'use client'
import { useState } from 'react'
export function ProductActions({ product }: { product: Product }) {
const [quantity, setQuantity] = useState(1)
return (
<div>
<input
type="number"
value={quantity}
onChange={e => setQuantity(Number(e.target.value))}
/>
<button onClick={() => addToCart(product.id, quantity)}>
Add to cart
</button>
</div>
)
}
The mistakes teams make
1. 'use client' on every component
A precaution directive turns the whole tree into Client Components and gives back the bundle RSC exist to remove. A Server Component imported by a Client Component becomes a Client Component too. Put the directive on the leaf that needs interactivity and keep the rest on the server.
2. A prop that does not cross the border
A Server Component passes a prop to a Client Component only if the prop serializes: string, number, array, plain object. Function, class instance, and object with methods do not cross, and the error shows up at runtime with React's serialization message.
// error: the function does not cross the border
<ClientComponent handler={someServerFunction} />
// correct: define the handler in the Client Component
// or call a Server Function from it
3. Server Function confused with fetching
Server Functions run on the server and accept calls from Client Components. They handle mutations. Reading stays with Server Components; writing goes through Server Functions.
4. The await waterfall
async/await in the body of a Server Component creates a waterfall when one call depends on the other by accident:
// waterfall: the second call waits for the first
const user = await fetchUser(id)
const orders = await fetchOrders(user.id)
// parallel: both leave together
const [user, orders] = await Promise.all([
fetchUser(id),
fetchOrders(id)
])
When the data does not depend on itself, Promise.all removes the dead time between the two calls. When the dependency is real, check whether the second call can take the id itself, without going through the first result.
The composition pattern
A well-built RSC page has a Server Component as root, running the query and composing the structure. Client Components sit at the leaves, near the interactivity. Subsections that need data and no interactivity go back to nested Server Components. The Next.js page on composition patterns shows this design with layout, Suspense, and streaming examples.
Enjoyed this content?
I build web products and AI solutions the right way — solid architecture, maintainable code, and real delivery.
Let's talk