React Server Components (RSC) fundamentally change how you think about data fetching and rendering. After a year of building with them in production, here are the patterns that work and the mistakes that cost time.
The Mental Model Shift
In the old model: everything renders on the client, data fetches in useEffect, loading states everywhere.
In the RSC model: components render on the server by default, data fetches are await calls, no loading state needed for initial render.
// Old pattern -- useEffect data fetching
function UserDashboard() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchUser().then(setUser).finally(() => setLoading(false))
}, [])
if (loading) return <Spinner />
return <div>{user?.name}</div>
}
// New pattern -- Server Component
async function UserDashboard() {
const user = await db.user.findUnique({ where: { id: getCurrentUserId() } })
return <div>{user?.name}</div>
}
No loading state. No useEffect. No client-side fetch. The component is async — it waits for the data.
The Component Boundary Decision
Every component is a Server Component unless it uses:
-
useStateoruseReducer -
useEffector lifecycle hooks - Browser APIs (
window,document, etc.) - Event handlers (
onClick,onChange) - Third-party libraries that use the above
If a component needs any of these, add 'use client' at the top. Keep 'use client' boundaries as leaf-level as possible.
// app/dashboard/page.tsx -- Server Component
async function DashboardPage() {
const [user, projects, stats] = await Promise.all([
getUser(),
getProjects(),
getStats(),
])
return (
<div>
<h1>{user.name}</h1>
<StatsGrid stats={stats} /> {/* Server Component */}
<ProjectList projects={projects} /> {/* Server Component */}
<NewProjectButton /> {/* 'use client' -- has onClick */}
</div>
)
}
Streaming with Suspense
Render the page shell immediately, stream in slow data:
import { Suspense } from 'react'
async function DashboardPage() {
// Fast -- render immediately
const user = await getUser()
return (
<div>
<h1>Welcome, {user.name}</h1>
{/* Slow query -- streams in after shell */}
<Suspense fallback={<StatsSkeleton />}>
<SlowAnalytics userId={user.id} />
</Suspense>
</div>
)
}
// SlowAnalytics fetches its own data -- doesn't block the page
async function SlowAnalytics({ userId }) {
const stats = await getExpensiveStats(userId) // takes 2 seconds
return <StatsChart data={stats} />
}
Passing Server Data to Client Components
// Server Component fetches, passes serializable data to client
async function ProductPage({ id }) {
const product = await db.product.findUnique({ where: { id } })
return (
<div>
<h1>{product.name}</h1>
{/* Pass only what the client component needs */}
<AddToCartButton
productId={product.id}
price={product.price}
inStock={product.inventory > 0}
/>
</div>
)
}
// 'use client'
function AddToCartButton({ productId, price, inStock }) {
// Client component -- can use state and event handlers
const [added, setAdded] = useState(false)
// ...
}
Server Actions
// Form with Server Action -- no API route needed
async function updateProfile(formData: FormData) {
'use server'
const session = await getServerSession()
await db.user.update({
where: { id: session.user.id },
data: { name: formData.get('name') as string },
})
revalidatePath('/dashboard/profile')
}
function ProfileForm({ user }) {
return (
<form action={updateProfile}>
<input name='name' defaultValue={user.name} />
<button type='submit'>Save</button>
</form>
)
}
Common Mistakes
-
Fetching in loops: Use
Promise.allnotfor...await -
Giant
'use client'boundaries: Moves too much to the client - Passing non-serializable data to client: Functions, class instances, Dates — serialize first
- Skipping Suspense: Without it, slow components block the entire page
The AI SaaS Starter at whoffagents.com is built RSC-first: parallel data fetching, Suspense boundaries on slow queries, and minimal use client footprint. $99 one-time.
Top comments (0)