---
name: server-components
description: React Server Components, Suspense boundaries, streaming SSR, partial prerendering patterns for Next.js App Router.
---
# React Server Components
RSC + Next.js App Router patterns for streaming, caching, and minimal client JS.
## RSC vs Client Components Decision Matrix
```
Use Server Component (default) when:
- Fetching data from DB / API
- Accessing backend resources (filesystem, secrets)
- No interactivity (useState, useEffect, event listeners)
- Heavy dependencies (no bundle cost)
Use Client Component ("use client") when:
- useState / useReducer / useRef
- useEffect / lifecycle hooks
- Browser APIs (window, navigator, IntersectionObserver)
- Event listeners (onClick, onChange)
- Third-party client libraries (charts, drag-drop)
Rule: Push "use client" as LOW in the tree as possible.
```
## "use client" / "use server" Directives
```typescript
// app/dashboard/page.tsx — Server Component (no directive needed)
import { db } from '@/lib/db'
import { StatsCounter } from './stats-counter' // client component
export default async function DashboardPage() {
const stats = await db.query.stats.findMany()
// Can pass serializable props to client components
return
}
// app/dashboard/stats-counter.tsx — Client Component
'use client'
import { useState } from 'react'
export function StatsCounter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount)
return
}
// Server Action in separate file
// app/actions.ts
'use server'
export async function createItem(formData: FormData) {
// runs on server, called from client
}
```
## Suspense Boundary Placement Strategy
```typescript
// Place Suspense around slow data — fast data renders immediately
export default async function Page() {
const fastData = await db.query.config.findFirst() // fast: cached
return (
{/* instant */}
}>
{/* streams in */}
}>
{/* streams in */}
)
}
async function SlowStats() {
const stats = await fetch('/api/stats', { cache: 'no-store' })
.then(r => r.json())
return
}
```
## Streaming SSR with loading.tsx
```
app/
dashboard/
page.tsx ← async server component (data fetching)
loading.tsx ← shown while page.tsx is streaming
error.tsx ← shown if page.tsx throws
layout.tsx ← wraps all, always renders immediately
```
```typescript
// app/dashboard/loading.tsx — instant skeleton, no async needed
export default function Loading() {
return (
{Array.from({ length: 3 }).map((_, i) => (
))}
)
}
```
## Partial Prerendering (PPR)
```typescript
// next.config.ts — enable PPR (Next.js 14+)
export default {
experimental: { ppr: true },
}
// page.tsx — static shell + dynamic holes
import { Suspense } from 'react'
import { unstable_noStore as noStore } from 'next/cache'
export default function ProductPage({ params }: { params: { id: string } }) {
return (
{/* Static: prerendered at build time */}
{/* Dynamic hole: streams in per request */}
}>
)
}
async function LivePrice({ id }: { id: string }) {
noStore() // opt out of caching — always fresh
const price = await fetchLivePrice(id)
return ${price}
}
```
## Server Actions Patterns
```typescript
// app/actions.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const Schema = z.object({
title: z.string().min(1).max(200),
priority: z.enum(['low', 'medium', 'high']),
})
// Form submission action
export async function createTask(prevState: unknown, formData: FormData) {
const parsed = Schema.safeParse({
title: formData.get('title'),
priority: formData.get('priority'),
})
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
await db.insert(tasks).values(parsed.data)
revalidatePath('/tasks') // invalidate cached page
revalidateTag('tasks') // invalidate tagged fetches
redirect('/tasks')
}
// Mutation action (called programmatically)
export async function deleteTask(id: string) {
await db.delete(tasks).where(eq(tasks.id, id))
revalidatePath('/tasks')
}
// Usage in client component
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { createTask } from './actions'
function SubmitButton() {
const { pending } = useFormStatus()
return
}
export function TaskForm() {
const [state, action] = useFormState(createTask, null)
return (
)
}
```
## Data Fetching in RSC (async components)
```typescript
// Fetch with Next.js extended fetch (auto deduplication + caching)
async function UserProfile({ id }: { id: string }) {
const user = await fetch(`/api/users/${id}`, {
next: { revalidate: 60, tags: ['users', `user-${id}`] }, // ISR: 60s
}).then(r => r.json())
return {user.name}
}
// Direct DB query (server only — no API round-trip)
import { db } from '@/lib/db'
async function TaskList() {
const tasks = await db.query.tasks.findMany({
where: (t, { eq }) => eq(t.userId, await getCurrentUserId()),
orderBy: (t, { desc }) => [desc(t.createdAt)],
})
return {tasks.map(t => - {t.title}
)}
}
```
## Cache and Revalidation
```typescript
import { unstable_cache } from 'next/cache'
import { cache } from 'react'
// React cache — deduplicate within a single request
const getUser = cache(async (id: string) => {
return db.query.users.findFirst({ where: (u, { eq }) => eq(u.id, id) })
})
// Next.js unstable_cache — persist across requests (like ISR)
const getCachedStats = unstable_cache(
async () => db.query.stats.findMany(),
['global-stats'],
{ revalidate: 300, tags: ['stats'] } // 5 min TTL
)
// Manual revalidation from Server Action
import { revalidateTag, revalidatePath } from 'next/cache'
export async function updateUser(id: string, data: unknown) {
await db.update(users).set(data).where(eq(users.id, id))
revalidateTag(`user-${id}`) // targeted cache bust
revalidatePath('/dashboard') // page cache bust
}
```
## Parallel Data Fetching in Layouts
```typescript
// Parallel: both requests fire simultaneously
export default async function Layout({ children }: { children: React.ReactNode }) {
const [user, notifications] = await Promise.all([
getUser(),
getNotifications(),
])
return (
{children}
)
}
```
## Error Boundary with error.tsx
```typescript
// app/dashboard/error.tsx — must be "use client"
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
Something went wrong
{process.env.NODE_ENV === 'development' ? error.message : 'An error occurred'}
)
}
```
## Common Pitfalls
```typescript
// PITFALL 1: Non-serializable props RSC → Client
// WRONG: passing functions, class instances, Dates
// functions not serializable
// Date not serializable
// RIGHT: serialize before passing
// PITFALL 2: Importing server-only code into client component
// Add 'server-only' package to throw at build time
import 'server-only' // add to lib/db.ts, lib/auth.ts
// PITFALL 3: Hydration mismatch (browser extensions, dynamic dates)
// WRONG:
{new Date().toLocaleString()} // server/client differ
// RIGHT: suppress or use useEffect
'use client'
const [time, setTime] = useState('')
useEffect(() => setTime(new Date().toLocaleString()), [])
```