| 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
import { db } from '@/lib/db'
import { StatsCounter } from './stats-counter'
export default async function DashboardPage() {
const stats = await db.query.stats.findMany()
return <StatsCounter initialCount={stats.length} />
}
'use client'
import { useState } from 'react'
export function StatsCounter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount)
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
'use server'
export async function createItem(formData: FormData) {
}
Suspense Boundary Placement Strategy
export default async function Page() {
const fastData = await db.query.config.findFirst()
return (
<div>
<Header config={fastData} /> {/* instant */}
<Suspense fallback={<StatsSkeleton />}>
<SlowStats /> {/* streams in */}
</Suspense>
<Suspense fallback={<FeedSkeleton rows={5} />}>
<ActivityFeed /> {/* streams in */}
</Suspense>
</div>
)
}
async function SlowStats() {
const stats = await fetch('/api/stats', { cache: 'no-store' })
.then( => r.())
}
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
export default function Loading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/3" />
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-24 bg-gray-200 rounded" />
))}
</div>
</div>
)
}
Partial Prerendering (PPR)
export default {
experimental: { ppr: true },
}
import { Suspense } from 'react'
import { unstable_noStore as noStore } from 'next/cache'
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* Static: prerendered at build time */}
<ProductShell id={params.id} />
{/* Dynamic hole: streams in per request */}
<Suspense fallback={<PriceSkeleton />}>
<LivePrice id={params.id} />
</Suspense>
</div>
)
}
async function LivePrice({ id }: { id: string }) {
noStore()
const price = await fetchLivePrice(id)
return
}
Server Actions Patterns
'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']),
})
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).(parsed.)
()
()
()
}
() {
db.(tasks).((tasks., id))
()
}
{ useFormState, useFormStatus }
{ createTask }
() {
{ pending } = ()
}
() {
[state, action] = (createTask, )
(
)
}
Data Fetching in RSC (async components)
async function UserProfile({ id }: { id: string }) {
const user = await fetch(`/api/users/${id}`, {
next: { revalidate: 60, tags: ['users', `user-${id}`] },
}).then(r => r.json())
return <div>{user.name}</div>
}
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)],
})
}
Cache and Revalidation
import { unstable_cache } from 'next/cache'
import { cache } from 'react'
const getUser = cache(async (id: string) => {
return db.query.users.findFirst({ where: (u, { eq }) => eq(u.id, id) })
})
const getCachedStats = unstable_cache(
async () => db.query.stats.findMany(),
['global-stats'],
{ revalidate: 300, tags: ['stats'] }
)
import { revalidateTag, revalidatePath } from 'next/cache'
export async function updateUser(id: string, data: unknown) {
await db.update(users).set(data).where((users., id))
()
()
}
Parallel Data Fetching in Layouts
export default async function Layout({ children }: { children: React.ReactNode }) {
const [user, notifications] = await Promise.all([
getUser(),
getNotifications(),
])
return (
<div>
<Header user={user} notificationCount={notifications.length} />
<main>{children}</main>
</div>
)
}
Error Boundary with error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div role="alert" className="p-6 border border-red-200 rounded-lg">
<h2 className="text-lg font-semibold text-red-800">Something went wrong</h2>
<p className="text-red-600 mt-1 text-sm">
{process.env.NODE_ENV === 'development' ? error.message : 'An error occurred'}
</p>
<button onClick={reset} className="mt-4 btn btn-sm">Try again</button>
</div>
)
}
Common Pitfalls
<ClientComp handler={someFunction} />
<ClientComp date={new Date()} />
<ClientComp dateString={date.toISOString()} />
<ClientComp timestamp={date.getTime()} />
import 'server-only'
<span>{new Date().toLocaleString()}</span> // server/client differ
'use client'
const [time, setTime] = useState<string>('')
useEffect(() => setTime(new Date().toLocaleString()), [])