A practical mental model for React Server Components. Covers the two-runtime architecture, serialization boundary rules, composition patterns, streaming with Suspense, caching layers, and common mistakes that break production apps.
Tyler McDaniel
AI Engineer & IBM Business Partner
React Server Components confused me for six months. The docs explain what they do but not why the architectural boundary exists where it does. Once I built the right mental model — think of it as two React runtimes collaborating across a serialization boundary — everything clicked. This guide builds that mental model from the ground up, with the gotchas and composition patterns I've learned shipping RSC in production on Next.js.
Traditional React runs entirely in the browser. Server-Side Rendering (SSR) generates HTML on the server, but then React "hydrates" in the browser and takes over. Every component ships JavaScript to the client. Every component hydrates.
React Server Components splits the world differently. Some components run only on the server. They never ship JavaScript to the client. They never hydrate. They render once, produce a serializable output (React's RSC wire format, not HTML), and the client runtime merges that output into the component tree.
The mental model:
Server Runtime Client Runtime
───────────────── ──────────────────
┌─────────────────┐ ┌──────────────────┐
│ Server Component│ ─── RSC ───> │ Client Component │
│ - async/await │ Wire │ - useState │
│ - DB access │ Format │ - useEffect │
│ - file system │ │ - event handlers │
│ - No state │ │ - Browser APIs │
│ - No effects │ │ - Hydrated │
└─────────────────┘ └──────────────────┘
Server Components are the default in Next.js App Router. Every component in app/ is a Server Component unless you add "use client" at the top. This is the opposite of traditional React — opt-in to interactivity rather than opt-in to server rendering.
async/await directly (they're async functions)prisma, fs, crypto)useState, useReducer, or any stateuseEffect, useLayoutEffect, or any effectonClick, onChange, etc.)window, document, localStorage)useContext (context is a client-side mechanism)// This is a Server Component (default in app/ directory)
// No "use client" directive = server component
import { db } from "@/lib/database";
async function CourseList() {
// Direct database access — no API route needed
const courses = await db.course.findMany({
where: { published: true },
orderBy: { enrollmentCount: "desc" },
take: 20,
});
return (
<ul className="space-y-4">
{courses.map((course) => (
<li key={course.id} className="rounded-lg border p-4">
<h3 className="text-lg font-semibold">{course.title}</h3>
<p className="text-gray-600">{course.description}</p>
<span className="text-sm text-gray-400">
{course.enrollmentCount} enrolled
</span>
</li>
))}
</ul>
);
}
Zero JavaScript shipped for this component. Zero hydration. The HTML renders on the server and streams to the client. The database query never leaks to the client bundle.
The "use client" directive creates a serialization boundary. When a Server Component renders a Client Component, it passes props across that boundary. Those props must be serializable — JSON-compatible values.
Serializable (can cross the boundary):
"use server")This constraint is where most confusion lives. You can't pass an onClick handler through a Server Component. But you can pass JSX children through a Client Component. This asymmetry enables the composition patterns below.
The most common and most useful pattern. The Server Component fetches data. The Client Component provides interactivity.
// app/dashboard/page.tsx (Server Component)
import { db } from "@/lib/database";
import { CourseCard } from "@/components/course-card"; // Client Component
import { EnrollButton } from "@/components/enroll-button"; // Client Component
export default async function DashboardPage() {
const courses = await db.course.findMany({
where: { published: true },
include: { instructor: true },
});
return (
<div className="grid grid-cols-3 gap-6">
{courses.map((course) => (
<CourseCard
key={course.id}
title={course.title}
instructor={course.instructor.name}
// Pass a Server Action instead of a callback
enrollAction={enrollInCourse.bind(null, course.id)}
>
<EnrollButton courseId={course.id} />
</CourseCard>
))}
</div>
);
}
// Server Action
async function enrollInCourse(courseId: string) {
"use server";
const session = await getSession();
await db.enrollment.create({
data: { userId: session.userId, courseId },
});
}
// components/course-card.tsx
"use client";
import { useState } from "react";
interface CourseCardProps {
title: string;
instructor: string;
children: React.ReactNode; // server-rendered children pass through
enrollAction: () => Promise<void>;
}
export function CourseCard({
title,
instructor,
children,
enrollAction,
}: CourseCardProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-lg border p-4">
<h3 className="font-semibold">{title}</h3>
<p className="text-sm text-gray-500">{instructor}</p>
<button onClick={() => setExpanded(!expanded)}>
{expanded ? "Less" : "More"}
</button>
{expanded && (
<div className="mt-2">
{children}
<form action={enrollAction}>
<button type="submit">Enroll</button>
</form>
</div>
)}
</div>
);
}
Notice: children is a React element passed from the Server Component through the Client Component. The EnrollButton renders inside CourseCard without CourseCard knowing or caring that it's a separate Client Component that hydrates independently.
When a Client Component needs to render Server Component content, pass it as children or named slots:
// app/layout.tsx (Server Component)
import { Sidebar } from "@/components/sidebar"; // Client Component (collapsible)
import { NavigationLinks } from "@/components/nav-links"; // Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar
navigation={<NavigationLinks />} {/ Server content as a slot /}
>
{children}
</Sidebar>
</div>
);
}
// components/sidebar.tsx
"use client";
import { useState } from "react";
export function Sidebar({
children,
navigation,
}: {
children: React.ReactNode;
navigation: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="flex">
<aside className={collapsed ? "w-16" : "w-64"}>
<button onClick={() => setCollapsed(!collapsed)}>Toggle</button>
{!collapsed && navigation} {/ Renders server content without hydrating it /}
</aside>
<main>{children}</main>
</div>
);
}
The NavigationLinks Server Component renders on the server. Its output passes through the Sidebar Client Component as a React element. The Sidebar manages collapse state. The navigation content doesn't re-render or hydrate — it's static server output.
Server Actions replace API routes for most mutations. They're async functions that run on the server, callable from Client Components:
// lib/actions.ts
"use server";
import { db } from "@/lib/database";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const SubmitAssignmentSchema = z.object({
assignmentId: z.string().uuid(),
content: z.string().min(1).max(10000),
});
export async function submitAssignment(formData: FormData) {
const session = await getSession();
if (!session) throw new Error("Unauthorized");
const parsed = SubmitAssignmentSchema.safeParse({
assignmentId: formData.get("assignmentId"),
content: formData.get("content"),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
await db.submission.create({
data: {
assignmentId: parsed.data.assignmentId,
studentId: session.userId,
content: parsed.data.content,
submittedAt: new Date(),
},
});
revalidatePath("/assignments");
return { success: true };
}
Server Actions are validated on the server, authenticated on the server, and the client only sends form data. No API route, no fetch call, no separate endpoint to maintain. The revalidatePath call tells Next.js to re-render the Server Component at that path, which shows the updated data without a full page reload.
RSC's streaming model changes how pages load. Instead of waiting for all data before showing anything, the server streams component output as it becomes ready. Suspense boundaries define the loading units:
// app/course/[id]/page.tsx (Server Component)
import { Suspense } from "react";
import { CourseHeader } from "@/components/course-header";
import { LessonList } from "@/components/lesson-list";
import { StudentProgress } from "@/components/student-progress";
import { DiscussionFeed } from "@/components/discussion-feed";
export default async function CoursePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<div className="space-y-8">
{/ Renders immediately — fast query /}
<Suspense fallback={<CourseHeaderSkeleton />}>
<CourseHeader courseId={id} />
</Suspense>
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2">
{/ Streams when lesson data is ready /}
<Suspense fallback={<LessonListSkeleton />}>
<LessonList courseId={id} />
</Suspense>
</div>
<div className="space-y-6">
{/ These can stream independently and in parallel /}
<Suspense fallback={<ProgressSkeleton />}>
<StudentProgress courseId={id} />
</Suspense>
<Suspense fallback={<DiscussionSkeleton />}>
<DiscussionFeed courseId={id} />
</Suspense>
</div>
</div>
</div>
);
}
Each Suspense boundary is an independent streaming unit. The server starts rendering all of them concurrently. As each async Server Component resolves, its output streams to the client and replaces the fallback. If CourseHeader resolves in 50ms and DiscussionFeed takes 800ms, the user sees the header immediately while the discussion feed shows a skeleton.
This is fundamentally different from SSR, where the server renders the entire page before sending anything. With RSC streaming, Time to First Byte is the time to render the fastest component, not the slowest.
A subtle but critical performance pattern: how you structure your component tree determines whether data fetches run in parallel or sequentially. Sequential (bad for performance):
// Each component awaits before rendering the next
async function CoursePage({ courseId }: { courseId: string }) {
const course = await db.course.findUnique({ where: { id: courseId } });
const lessons = await db.lesson.findMany({ where: { courseId } }); // Waits for course
const progress = await db.progress.findFirst({ where: { courseId } }); // Waits for lessons
return (
<>
<CourseHeader course={course} />
<LessonList lessons={lessons} />
<StudentProgress progress={progress} />
</>
);
}
Parallel (use this):
// Each query is its own Server Component, fetched concurrently via Suspense
async function CourseHeader({ courseId }: { courseId: string }) {
const course = await db.course.findUnique({ where: { id: courseId } });
return <h1>{course?.title}</h1>;
}
async function LessonList({ courseId }: { courseId: string }) {
const lessons = await db.lesson.findMany({ where: { courseId } });
return <ul>{lessons.map((l) => <li key={l.id}>{l.title}</li>)}</ul>;
}
async function StudentProgress({ courseId }: { courseId: string }) {
const progress = await db.progress.findFirst({ where: { courseId } });
return <div>Progress: {progress?.percent}%</div>;
}
When these are wrapped in separate Suspense boundaries (as in the example above), React initiates all three queries concurrently. The page loads as fast as the slowest query, not the sum of all queries. This is free parallelism — no Promise.all, no explicit concurrency management. The component tree topology is the concurrency model.
Next.js provides multiple caching layers. Understanding which layer applies where prevents both stale-data bugs and unnecessary re-fetches.
fetch() caching (built into Next.js): When you call fetch() inside a Server Component, Next.js automatically deduplicates and caches the result per-request. Same URL called in two different Server Components during the same render? One network request.
// Both components call the same URL — Next.js deduplicates automatically
async function CourseTitle({ courseId }: { courseId: string }) {
const res = await fetch(https://api.example.com/courses/${courseId});
const course = await res.json();
return <h1>{course.title}</h1>;
}
async function CourseMeta({ courseId }: { courseId: string }) {
const res = await fetch(https://api.example.com/courses/${courseId});
const course = await res.json();
return <meta name="description" content={course.description} />;
}
unstable_cache for database queries: fetch() caching doesn't apply to direct database calls. Use unstable_cache (stable in practice, just cautiously named) to cache Prisma or raw SQL results:
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
const getCourseWithCache = unstable_cache(
async (courseId: string) => {
return db.course.findUnique({
where: { id: courseId },
include: { lessons: true, instructor: true },
});
},
["course-detail"], // Cache key prefix
{
revalidate: 3600, // Revalidate every hour
tags: ["courses"], // Tag-based invalidation
}
);
// In a Server Component:
async function CoursePage({ courseId }: { courseId: string }) {
const course = await getCourseWithCache(courseId);
// ...
}
Tag-based invalidation: When data changes, invalidate by tag instead of path:
"use server";import { revalidateTag } from "next/cache";
export async function updateCourse(courseId: string, data: CourseUpdateInput) {
await db.course.update({ where: { id: courseId }, data });
revalidateTag("courses"); // Invalidates all caches tagged "courses"
}
Static vs Dynamic rendering: Next.js decides at build time whether a route is static or dynamic. If a Server Component accesses headers, cookies, or search params, the entire route becomes dynamic (rendered per-request). If it only uses cached data, the route is static (rendered at build time, like SSG). You can force dynamic with export const dynamic = "force-dynamic" or force static with export const dynamic = "force-static".The gotcha: a single cookies() call anywhere in the component tree makes the entire route dynamic. If only one component needs cookies (e.g., for auth), extract it into a separate Suspense boundary so the rest of the page can be statically cached.
| Feature | RSC | SSR (Pages Router) | SSG (getStaticProps) | ISR |
|---------|-----|---------------------|---------------------|-----|
| JavaScript shipped | Only client components | All components | All components | All components |
| Hydration cost | Partial (client only) | Full | Full | Full |
| Data fetching | In-component async/await | getServerSideProps | getStaticProps | getStaticProps + revalidate |
| Streaming | Yes (Suspense) | Limited | No | No |
| Component-level caching | Yes (cache(), unstable_cache) | No | Page-level only | Page-level only |
| Database access in components | Yes | No (props only) | No (props only) | No (props only) |
| Bundle size impact | Lower (server code excluded) | Higher | Higher | Higher |
| First Contentful Paint | Fast (streaming) | Fast | Fastest (static) | Fast |
| Time to Interactive | Fast (less JS) | Slower (full hydration) | Slower (full hydration) | Slower (full hydration) |
| Complexity | Higher (mental model) | Lower | Lowest | Low |
"use client" boundary is also a Client Component. If you put "use client" on your layout, every page is a Client Component. Push the directive as deep as possible — to the leaf components that actually need interactivity.
Mistake 2: Fetching data in Client Components via useEffect. With RSC, the idiomatic pattern is: Server Component fetches data, passes it as props to Client Components. If you're writing useEffect(() => { fetch('/api/...') }, []) in App Router, you're probably doing it wrong. The exception is real-time data that needs client-side polling or WebSocket updates.
Mistake 3: Importing a server module in a Client Component. This will error at build time (or worse, leak server-side code to the client). Use the server-only package to get clear build errors:
// lib/database.ts
import "server-only"; // Build error if imported from client component
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();
Mistake 4: Thinking Server Components re-render on user interaction. Server Components render once per navigation or revalidation. They do not re-render when state changes in a sibling Client Component. If you need a Server Component to re-render, call router.refresh() or use revalidatePath() in a Server Action.
Mistake 5: Trying to pass non-serializable props across the boundary. The error messages for serialization failures have improved, but the fundamental constraint remains. If you find yourself trying to pass a callback function, a class instance, or a Map to a Client Component, restructure with Server Actions or the slot pattern instead.
Mistake 6: Not understanding the "use client" infection boundary. When you add "use client" to a file, every module imported by that file is also bundled as client code. If your Client Component imports a utility that imports Prisma, Prisma ends up in your client bundle — and the build fails. The fix: restructure imports so client-only code never touches server-only code. Use barrel files carefully, or don't use them at all in App Router projects. A single export * from './server-utils' in an index.ts can accidentally pull server code into the client.
Mistake 7: Overusing "use client" for components that only need it for children. If a component just needs to receive interactive children but has no state or effects itself, it might not need "use client". Consider whether the slot pattern can keep it as a Server Component.
Testing Server Components requires a different approach than testing Client Components. You can't render them with @testing-library/react in a jsdom environment because they're async and use server-only APIs.
Unit testing Server Components: Treat them as async functions that return JSX:
// __tests__/course-header.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import { CourseHeader } from "@/components/course-header";
// Mock the database module
vi.mock("@/lib/database", () => ({
db: {
course: {
findUnique: vi.fn().mockResolvedValue({
id: "1",
title: "Intro to React",
description: "Learn React from scratch",
}),
},
},
}));
describe("CourseHeader", () => {
it("renders course title", async () => {
// Server Components are async functions — await them
const jsx = await CourseHeader({ courseId: "1" });
const { getByText } = render(jsx);
expect(getByText("Intro to React")).toBeInTheDocument();
});
});
Integration testing with Server Actions: Test the action logic independently of the component:
// __tests__/actions.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { submitAssignment } from "@/lib/actions";
vi.mock("@/lib/database");
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
describe("submitAssignment", () => {
it("validates input and creates submission", async () => {
const formData = new FormData();
formData.set("assignmentId", "550e8400-e29b-41d4-a716-446655440000");
formData.set("content", "My assignment solution...");
const result = await submitAssignment(formData);
expect(result).toEqual({ success: true });
});
it("rejects invalid UUID", async () => {
const formData = new FormData();
formData.set("assignmentId", "not-a-uuid");
formData.set("content", "My solution");
const result = await submitAssignment(formData);
expect(result.error).toBeDefined();
});
});
E2E testing: For full RSC streaming behavior, use Playwright. It tests the actual server rendering, streaming, and hydration as users experience them. This is the only way to verify that Suspense boundaries stream correctly and Client Components hydrate with the right props. One non-obvious benefit: Playwright catches "use client" boundary mistakes that unit tests miss, because it exercising the actual serialization path between server and client runtimes.The testing strategy I follow: unit test Server Actions (pure logic, easy to mock), snapshot test Server Component output (async rendering produces predictable JSX), and E2E test the critical user flows that cross the server/client boundary. Skip unit testing individual Server Components unless they have complex conditional rendering — the integration and E2E tests catch the real bugs.
RSC adds complexity. If your app is a rich interactive SPA — a real-time collaboration tool, a game, a drag-and-drop builder — every component needs client-side state and the server/client split provides little benefit. You'd be fighting the model instead of leveraging it.
RSC also doesn't help if your data sources are all client-side. An app that reads from localStorage, IndexedDB, or a local-first CRDT doesn't benefit from server rendering because there's nothing to render on the server. Same for offline-first PWAs where the server might not be reachable.
The third case: if your team doesn't have the mental bandwidth for a new paradigm. RSC requires understanding the serialization boundary, the "use client" infection model, the caching layers, and the streaming behavior. A team that's productive with Pages Router and getServerSideProps might not gain enough to justify the migration effort. I migrated [this site from Pages Router to App Router](https://tostupidtooquit.com/blog/nextjs-16-tailwind-v4-migration-guide) and it was worth it — but this is a content-heavy site where the RSC model is a natural fit.
RSC shines when you have a mix of static or data-driven content (Server Components) with pockets of interactivity (Client Components). Dashboards, content sites, e-commerce, learning platforms — these are the sweet spots. This site (tostupidtooquit.com) uses RSC extensively: the blog pages are Server Components that read content at build/request time, with Client Components only for the mobile nav toggle, theme switcher, and Calendly embed. The result is a Lighthouse score we're happy with and a tiny JavaScript bundle.
The mental model — two runtimes, a serialization boundary, and composition via children — takes time to internalize but simplifies everything once it clicks. Stop thinking about "server vs client" as rendering strategies and start thinking about them as runtime environments with different capabilities. The boundary between them is a contract, not a limitation.
For maximizing type safety in these component boundaries, [TypeScript's advanced patterns](https://tostupidtooquit.com/blog/typescript-advanced-patterns) — particularly branded types and discriminated unions — help catch serialization issues at compile time rather than runtime.
---
A real migration story from Next.js 15.1 with Tailwind v3.4 to Next.js 16 with Tailwind v4. Covers the CSS-first config system, OKLCH color conversion, Turbopack landmines, and every class name that changed.
A three-tier token architecture (primitive, semantic, component) with CSS custom properties, Style Dictionary transforms, Tailwind v4 integration, and dark mode that's a token swap instead of a stylesheet rewrite.
Production TypeScript patterns: const type parameters, satisfies operator, template literal types for API routes, branded types for domain safety, discriminated unions, and the builder pattern with full type inference.