The server/client boundary is the foundational mental model for React Server Components
In React's new model, every component has a designated runtime β either the server or the browser. Server Components run once at request time with zero client bundle cost, while Client Components hydrate in the browser and power all interactivity. The boundary between them is called the serialization boundary, and understanding it unlocks the full power of the RSC model.
Each component type has a distinct set of capabilities and constraints.
Direct Data Access
Databases, file systems, and backend services β no API layer
Secrets Stay Secret
API keys and tokens never leave the server environment
Zero Client JS
Component code is never shipped to the browser
Async by Default
Await data directly in the component body with no hooks
Not available on server:
// No directive β Server Component by default
async function PokemonPage() {
// β Direct DB β zero extra API layer
const list = await db.pokemon.findMany({
orderBy: { name: "asc" },
);
// β Secrets never reach the browser
const key = process.env.STRIPE_KEY;
return <PokemonGrid list={list} />;
}Interactivity
onClick, onChange, form submissions, and all event handlers
Local State
useState, useReducer, and useContext for reactive UI
Browser APIs
window, localStorage, geolocation, and Web APIs
Lifecycle Hooks
useEffect for side effects, timers, and subscriptions
Not available on client:
"use client" // β opts the file into the client bundle
import { useState } from "react"
function FavoriteButton() {
// β Client state β not possible on server
const [liked, setLiked] = useState(false);
return (
<button onClick={()=> setLiked(!liked)}
className{liked ? "text-red-500" : "text-gray-400"}
> {liked ? "β€οΈ" : "π€"} </button>
);
}Server Components can render Client Components β the reverse is not allowed at the module level.
// Server Component β no directive needed
import { FavoriteButton } from "@/components/FavoriteButton"
async function Page() {
const data = await fetchPokemon();
return (
<div>
<PokemonCard data={data} /> {/* Server */}
<FavoriteButton /> {/* Client β */}
</div>
);
}"use client"
import { ServerFeed } from "@/components/ServerFeed" // β
function ClientWrapper() {
// β ServerFeed gets pulled into client bundle
return <ServerFeed />
}
// β Fix β accept as children instead
function ClientWrapper({ children }) {
return <div>children</div>
}Serialization Boundary
All props crossing from Server β Client must be serializable JSON. No functions, class instances, Symbols, Maps, or Sets. Use plain objects, arrays, strings, numbers, and booleans. Server Actions are the only exception β React serializes them as references.
React Query for client-side data fetching with caching
Build resilient UIs with Suspense boundaries and Error Boundaries
What it does:
Shows a fallback UI while async components are loading. Enables streaming and progressive rendering.
When to use:
Wrap async Server Components to show loading skeletons while data fetches.
<Suspense fallback={<Skeleton />}>
<AsyncComponent />
</Suspense>What it does:
Catches JavaScript errors in child components and displays a fallback UI instead of crashing.
When to use:
Wrap components that might fail (network errors, missing data) to prevent whole-page crashes.
<ErrorBoundary fallback={<Error />}>
<ComponentThatMightFail />
</ErrorBoundary>π‘ Combine Both for Resilience
Use Suspense for loading states and Error Boundaries for failures. This pattern ensures users see appropriate feedback whether components are loading or have failed.
One boundary around multiple components. If any fails, the entire section shows an error.
<ErrorBoundary>
<Suspense fallback={<Loading />}>
<Card1 />
<Card2 />
<Card3 />
</Suspense>
</ErrorBoundary>Behavior:
Separate boundaries for each component. Failures and loading are isolated to individual cards.
{cards.map(card => (
<ErrorBoundary key={card.id}>
<Suspense fallback={<Skeleton />}>
<Card {...card} />
</Suspense>
</ErrorBoundary>
))}Behavior:
Each card has ~30% chance to fail. Refresh to see different outcomes.
Notice: Some cards may show errors while others load successfully. This is the power of isolated boundariesβfailures don't cascade. Without individual boundaries, one error would break the entire grid.
Understanding the patterns and best practices
Multiple independent requests execute simultaneously for optimal performance
Best for:
Requests execute one after another when dependencies exist
Best for:
Progressive rendering as data becomes available
Best for:
See how layouts persist while content streams in. Experience the power of partial rendering.
Explore Navigation Demo βStreaming data with independent Suspense boundaries
Control data freshness and performance with Next.js caching strategies
Deduplicates identical fetch calls within a single render pass. Fully automatic β zero config.
Stores fetch results across requests and deployments. Configured via revalidate or cache tags.
Pre-renders and stores full page HTML at build time. Dynamic routes opt out automatically.
Performance Insight
Caching dramatically improves performance by reducing redundant work. The examples below show different strategies for balancing freshness and speed.
1 β tag the fetch
await fetch(url, {
next: { tags: ['pokemon-list'] }
})2 β invalidate after mutation
revalidateTag('pokemon-list')β¦ Best for
Event-driven updates β shopping carts, user profiles, after Server Actions
Data changes on a predictable schedule
Product catalog, blog posts, sports scores
Invalidate after user mutations or events
Shopping cart, user profile, CMS content
Data must always be current per request
Live feeds, auth-gated pages, dashboards
await fetch(url, {
cache: 'no-store'
})Fetched at: 11:13:16 AM
(Refresh the page to see the timestamp update)
β¦ Best for
Personalized data, real-time feeds, auth-gated pages
fetch(..., {next: { revalidate: 60 }}})
This data is cached and revalidated every 60 seconds. Multiple requests within that window use the cached version.
Fetched at: 11:13:16 AM
β¦ Best for
Data that changes predictably β product prices, blog posts, sports scores
Server fetches data, Client handles interactivity
π Key Concept: Serialization Boundary
The server sends plain JavaScript objects (JSON) to the client. No functions, class instances, or Symbols can cross this boundary. Notice how we transform the API response into a simple object with strings and numbers before passing it down.
Component Tree:
ServerClientInteropDemo (fetches data)InteractivePokemonList (manages filter state)TypeFilter (handles clicks)Client-side filtering (interactive)
InteractivePokemonCard (favorite button, hover states)First Request
ditto
294ms
Second Request
mew
139ms
Total Time
433ms
294ms + 139ms
Best used when:
Multiple independent requests resolved simultaneously with Promise.allSettled
const [pokemonListResult, neinResult] = await Promise.allSettled([ PokemonAPI.get("/pokemon?limit=5"), // resolves independently NeinAPI.get(""), // won't block if this fails ]);
Request Status
/pokemon?limit=5/Total PokΓ©mon
1350
Load Time
1206ms
both requests combined
Fetched PokΓ©mon
Nein API Response
I put the 'pro' in procrastinate, and I won't ruin my streak.
bulbasaur
pikachu
charizard