React Server Components Cheat Sheet
Syntax and rules for RSC: server vs client component boundaries, the 'use client' directive, async components, and data fetching.
Default Server Component
Components are Server Components by default (in RSC-enabled frameworks like Next.js App Router) — no directive needed.
// app/products/page.tsx — runs only on the serverimport { db } from '@/lib/db'export default async function ProductsPage() { const products = await db.product.findMany() // direct DB access, no API route return ( <ul> {products.map((p) => ( <li key={p.id}>{p.name}</li> ))} </ul> )}
'use client' Boundary
Opt a subtree into client rendering when you need interactivity, state, or browser APIs.
'use client'import { useState } from 'react'export function LikeButton({ initialLikes }: { initialLikes: number }) { const [likes, setLikes] = useState(initialLikes) return <button onClick={() => setLikes(likes + 1)}>{likes} likes</button>}// server component passes serializable props down// export default async function Page() {// const post = await getPost()// return <LikeButton initialLikes={post.likes} />// }
Passing Server Components as Children
Render a Server Component inside a Client Component via the children prop, keeping the child server-rendered.
'use client'export function ClientShell({ children }: { children: React.ReactNode }) { const [open, setOpen] = useState(false) return <div onClick={() => setOpen(!open)}>{open && children}</div>}// server component// import { ClientShell } from './ClientShell'// export default async function Page() {// const data = await fetchData() // stays server-only// return <ClientShell><ServerRenderedDetails data={data} /></ClientShell>// }
Server Actions
Call server-only mutation functions directly from client forms without hand-rolling an API route.
// app/actions.ts'use server'export async function createTodo(formData: FormData) { const title = formData.get('title') as string await db.todo.create({ data: { title } })}// app/todo-form.tsximport { createTodo } from './actions'export function TodoForm() { return ( <form action={createTodo}> <input name="title" /> <button type="submit">Add</button> </form> )}
Server vs Client Component Rules
What you can/can't do on each side of the boundary.
- Server Components- can be async, access DB/filesystem/secrets directly, never re-render on the client, ship zero JS
- Client Components ('use client')- can use useState/useEffect/browser APIs, hydrate and re-render like classic React
- Props crossing the boundary- must be serializable (no functions, class instances, or Dates without a transform)
- Server Components inside Client Components- allowed only via the children/slot pattern, not by direct import
- 'use server' (Server Actions)- marks a function callable from the client that always executes on the server
Streaming with Suspense
Wrap a slow async Server Component in Suspense so the shell paints immediately and the slow part streams in later.
import { Suspense } from 'react'export default function Page() { return ( <div> <h1>Dashboard</h1> <Suspense fallback={<StatsSkeleton />}> <SlowStats /> </Suspense> </div> )}async function SlowStats() { const stats = await db.stats.aggregate() // takes 2s, doesn't block the shell return <StatsPanel data={stats} />}
useOptimistic + useFormStatus
Pair Server Actions with client hooks to show instant UI feedback before the server round trip resolves.
'use client'import { useOptimistic, useTransition } from 'react'import { likePost } from './actions'export function LikeButton({ postId, likes }: { postId: string; likes: number }) { const [optimisticLikes, addOptimistic] = useOptimistic(likes, (state) => state + 1) const [, startTransition] = useTransition() return ( <button onClick={() => startTransition(async () => { addOptimistic(null) await likePost(postId) })} > {optimisticLikes} likes </button> )}
Cache Invalidation: revalidatePath / revalidateTag
Server Actions can selectively bust the Next.js cache after a mutation instead of forcing a full reload.
// app/actions.ts'use server'import { revalidatePath, revalidateTag } from 'next/cache'export async function publishPost(id: string) { await db.post.update({ where: { id }, data: { published: true } }) revalidatePath(`/blog/${id}`) // re-render that path on next request revalidateTag('posts') // bust every fetch tagged 'posts'}// tagging a fetch for later invalidation// fetch('/api/posts', { next: { tags: ['posts'] } })
Wrapping Context Providers for Server Trees
React Context only works in Client Components, so isolate providers in a thin client wrapper near the root, not throughout the tree.
// app/providers.tsx'use client'import { ThemeProvider } from 'next-themes'export function Providers({ children }: { children: React.ReactNode }) { return <ThemeProvider attribute="class">{children}</ThemeProvider>}// app/layout.tsx (server component)import { Providers } from './providers'export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <Providers>{children}</Providers> {/* server children still render on the server */} </body> </html> )}
Gotchas You Hit Past the Basics
Mistakes that only surface once an app grows beyond the tutorial examples.
- Passing functions as props- server → client props must be serializable; pass a Server Action reference instead of a closure
- Third-party client libraries- packages without 'use client' internally (older UI kits) need a local re-export wrapper marked 'use client'
- Module-level singletons in Server Components- re-created per request in serverless, not shared state — use a real cache/DB for cross-request state
- Suspense boundaries and layout shift- give fallbacks the same dimensions as real content to avoid CLS when streaming resolves
- Server Actions and CSRF- Next.js checks the Origin header automatically, but self-hosted deployments behind some proxies must forward it correctly
The 'use client' directive marks the top of a subtree, not a single file in isolation — everything that file imports (unless it's also marked, or is a shared library) gets bundled into the client boundary too, so put it as low in the tree as possible to keep the server-rendered surface large.