Engineering
Overview
RSCs aren't just an optimization — they're a completely new way to think about data fetching and rendering. Here's the mental model that finally made it click.
Jordan Mercer
When I first heard about React Server Components, I was confused. "Components that run on the server? Isn't that just... server-side rendering?"
No. It's fundamentally different. And once the mental model clicks, you'll wonder how you ever built apps without them.
React Server Components architecture diagram
In traditional React:
// Everything runs on the client
function Dashboard() {
const [user, setUser] = useState(null)
const [posts, setPosts] = useState([])
useEffect(() => {
fetch('/api/user').then(setUser)
fetch('/api/posts').then(setPosts)
}, [])
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
</div>
)
}
Problems:
In RSC:
// Server Component (no 'use client' directive)
async function Dashboard() {
// Direct database access! No API endpoint needed
const user = await db.user.findUnique({ where: { id: session.userId } })
const posts = await db.post.findMany({ where: { authorId: user.id } })
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
</div>
)
}
// Client Component ('use client' at top)
'use client'
function PostList({ posts }) {
const [sortOrder, setSortOrder] = useState('desc')
// Interactive but already has data from server
const sortedPosts = sortPosts(posts, sortOrder)
return (
<>
<button onClick={() => setSortOrder('asc')}>Sort Asc</button>
{sortedPosts.map(post => <Post key={post.id} post={post} />)}
</>
)
}
Server vs client component comparison
// ✅ Use for: Data fetching, database access, heavy computations
async function ProductPage({ params }) {
const product = await db.product.findUnique({
where: { slug: params.slug }
})
return (
<div>
<h1>{product.name}</h1>
<Price product={product} />
<AddToCart productId={product.id} />
</div>
)
}
// ✅ Use for: useState, useEffect, event handlers, browser APIs
'use client'
function AddToCart({ productId }) {
const [quantity, setQuantity] = useState(1)
return (
<div>
<input
type="number"
value={quantity}
onChange={e => setQuantity(e.target.value)}
/>
<button onClick={() => addToCart(productId, quantity)}>
Add to Cart
</button>
</div>
)
}
// No 'use client' = defaults to server, but safe on client
function Price({ product }) {
// Just rendering, no state or effects
return <span className="price">${product.price}</span>
}
// Can be imported into server OR client components
Client request → HTML (empty) → JS loads → API call 1 → API call 2 → Render
Server request → Fetch all data in parallel → Render HTML + data → Client
Real performance impact on a typical dashboard:
Traditional (Client-side fetch): 1800ms to interactive RSC: 400ms to interactive
// Server Component with Suspense boundaries
import { Suspense } from 'react'
async function Dashboard() {
return (
<div>
<Suspense fallback={<UserProfileSkeleton />}>
{/* This fetches independently */}
<UserProfile />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
{/* This fetches independently */}
<PostsFeed />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
{/* This too! */}
<AnalyticsWidget />
</Suspense>
</div>
)
}
// Each component fetches its own data
async function UserProfile() {
const user = await db.user.findUnique(...)
return <div>{user.name}</div>
}
async function PostsFeed() {
const posts = await db.post.findMany(...)
return <PostList posts={posts} />
}
Suspense streaming visualization
// app/layout.js (Server Component)
export default function Layout({ children }) {
// Runs once per request, not per navigation
const theme = await getThemeFromDatabase()
return (
<html data-theme={theme}>
<body>{children}</body>
</html>
)
}
// Keep client components small and at the leaves
function InteractiveSection() {
return (
<div>
<h1>Static header (server)</h1>
{/* Only this button is interactive */}
<ClientSidebar>
<ServerContent /> {/* This still runs on server! */}
</ClientSidebar>
</div>
)
}
// Server Component provides data
async function App() {
const user = await getUser()
return (
<ClientProvider initialUser={user}>
<ClientComponentThatNeedsUser />
</ClientProvider>
)
}
// Client Component provides context
'use client'
function ClientProvider({ children, initialUser }) {
const [user] = useState(initialUser)
return (
<UserContext.Provider value={user}>
{children}
</UserContext.Provider>
)
}
❌ Bad:
'use client' // Entire app becomes client component
export default function App() { ... }
✅ Good:
// No 'use client' here (defaults to server)
import ClientButton from './ClientButton'
export default function App() {
return (
<div>
<h1>Server rendered title</h1>
<ClientButton /> {/* Only this is interactive */}
</div>
)
}
❌ Bad:
'use client'
function Posts() {
const [posts, setPosts] = useState([])
useEffect(() => {
fetch('/api/posts').then(setPosts) // Waterfall + extra API
}, [])
}
✅ Good:
async function Posts() {
const posts = await db.post.findMany() // Direct + parallel
return <ClientPostsList posts={posts} />
}
Step 1: Convert data-fetching pages to Server Components Step 2: Move interactive parts into client components Step 3: Remove API endpoints that are only used by your own app Step 4: Add Suspense boundaries Step 5: Measure the performance improvement
E-commerce product page:
Content-heavy blog:
RSCs aren't just an optimization — they're a paradigm shift. They let you:
The mental model is simple: Server for data, client for interactivity. Everything else is implementation detail.
Once you build with RSCs, traditional client-only React feels like programming with one hand tied behind your back.
Written by
Jordan Mercer
Keep Reading
A deep-dive into the architecture patterns, folder structures, and performance strategies that keep large Next.js codebases maintainable as they grow.
After using Prisma on dozens of production apps, we've settled on a set of patterns for migrations, seeding, relations, and query optimization.
We turned on strict mode across a 60k-line codebase. Here's every error we hit, how we fixed them, and why we'd do it again.
Let's work together