React Server Components Patterns
Mental Model
┌─────────────────────────────────────────────┐
│ Server │
│ ┌────────────────────────────────────────┐ │
│ │ RSC Tree (async, no hooks, no events) │ │
│ │ │ │
│ │ <Page> ← Server │ │
│ │ <Header> ← Server │ │
│ │ <ProductList> ← Server (DB call) │ │
│ │ <AddToCart> ← 'use client' ─── │──┼─→ Client bundle
│ │ <Footer> ← Server │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Server Components can:
async/awaitdirectly- Access databases, file system, env vars
- Import server-only packages
Server Components cannot:
- Use
useState,useEffect, event handlers - Access browser APIs
- Be passed callbacks as props
Data Fetching in RSC
// app/products/page.tsx — Server Component
async function ProductsPage({ searchParams }: { searchParams: { q?: string } }) {
// Direct DB access — no API layer needed
const products = await prisma.product.findMany({
where: searchParams.q
? { name: { contains: searchParams.q, mode: 'insensitive' } }
: undefined,
include: { category: true },
orderBy: { createdAt: 'desc' },
})
return (
<main>
<h1>Products</h1>
<Suspense fallback={<ProductListSkeleton count={products.length} />}>
<ProductList products={products} />
</Suspense>
</main>
)
}
Parallel Data Fetching
// DO: fetch in parallel when data is independent
async function DashboardPage() {
const [user, analytics, notifications] = await Promise.all([
getUser(),
getAnalytics(),
getNotifications(),
])
return (
<div>
<Header user={user} />
<Analytics data={analytics} />
<NotificationBell notifications={notifications} />
</div>
)
}
// DO: initiate fetches early (Request Waterfall mitigation)
async function ProductPage({ params }: { params: { id: string } }) {
const productPromise = getProduct(params.id)
const recommendedPromise = getRecommended(params.id) // starts in parallel
const product = await productPromise // await only when needed
return (
<div>
<ProductDetail product={product} />
<Suspense fallback={<Skeleton />}>
<Recommended promise={recommendedPromise} />
</Suspense>
</div>
)
}
Client Boundary
// Push the 'use client' boundary as deep as possible
// ❌ WRONG: entire section becomes client bundle
'use client'
async function ProductList({ products }: { products: Product[] }) {
// now you can't use await here anyway
}
// ✓ CORRECT: only interactive leaf is a Client Component
async function ProductList({ products }: { products: Product[] }) {
return (
<ul>
{products.map(p => (
<li key={p.id}>
{p.name}
<AddToCartButton productId={p.id} /> {/* 'use client' */}
</li>
))}
</ul>
)
}
Passing Data Through Boundaries
// Serializable props pass the boundary — RSC serializes them
<ClientComponent
id={product.id} // ✓ string
name={product.name} // ✓ string
price={product.price} // ✓ number
tags={product.tags} // ✓ string[]
createdAt={product.createdAt} // ✓ Date (serialized)
/>
// Functions cannot pass the boundary from Server → Client
// Use Server Actions instead
Server Actions in Forms
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
})
export async function createPost(prevState: any, formData: FormData) {
const parsed = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
})
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }
}
const post = await db.post.create({ data: parsed.data })
revalidatePath('/posts')
redirect(`/posts/${post.id}`)
}
// Client Component using the action
'use client'
import { useActionState } from 'react'
import { createPost } from '../actions'
function CreatePostForm() {
const [state, action, isPending] = useActionState(createPost, null)
return (
<form action={action}>
<input name="title" required />
{state?.errors?.title && <span>{state.errors.title}</span>}
<textarea name="content" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}
Caching & Revalidation
// Per-request cache (deduplicate within a render)
import { cache } from 'react'
const getUser = cache(async (id: string) => {
return prisma.user.findUnique({ where: { id } })
})
// Both calls hit DB only once per request
const user1 = await getUser('123')
const user2 = await getUser('123') // cache hit
// Next.js: tag-based cache invalidation
const post = await fetch(`/api/posts/${id}`, {
next: { tags: [`post:${id}`] }
})
// Invalidate from Server Action:
revalidateTag(`post:${id}`)
Server-Only Utilities
// lib/db.server.ts — cannot be imported in Client Components
import 'server-only' // throws at build if imported in client bundle
import { PrismaClient } from '@prisma/client'
export const db = new PrismaClient()