"I Just Want To Show A List Of Users"
That used to be a simple sentence. In a Next.js Pages Router or a Vite SPA the journey looked like this: define a database query in the API layer, expose it as /api/users, write a useUsers hook with React Query, render a <UserList> component, manage loading and error states, handle the case where the user navigates away mid-fetch, and remember to revalidate the cache when something changes.
Six files. Two network hops (browser to your API, your API to your database). A loading spinner. A serialization boundary. All to render some names.
React Server Components delete most of that scaffolding. You write one async function that queries the database and returns JSX. It runs on the server. The browser receives the rendered output. There is no API route in the middle, no useEffect, no client-side cache, no spinner. Whether that's a good thing or a deeply uncomfortable thing depends on how attached you are to the old shape — but the architecture has already moved.
The Model In One Paragraph
A Server Component is a React component that runs only on the server. It can be async. It can await the database, the filesystem, or any backend service. It cannot use useState, useEffect, or any browser-only API. When the response is sent to the browser, the Server Component is gone — its JavaScript never travels. Only the rendered output does.
A Client Component is the React component you already know. It runs in the browser, uses hooks, handles events. To mark a file as a Client Component you put "use client" at the top. Server Components can render Client Components. Client Components cannot render Server Components — they receive them only as props (children or other named slots).
That's the whole spec. The architectural consequences ripple out from those rules.
Data Fetching Without The Ceremony
Here is the entire data layer for a user list page in Next.js 15:
// app/users/page.tsx
import { db } from '@/lib/db';
export default async function UsersPage() {
const users = await db.user.findMany({ orderBy: { createdAt: 'desc' } });
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
That's it. No API route. No hook. No loading state in this file (Next.js handles that with loading.tsx). No error state (that's error.tsx). The function runs on the server, queries the database directly, returns the rendered tree.
This is the change people miss when they describe RSCs as "just a new feature." It isn't a new feature — it's the deletion of an entire layer. The API route you used to need was a translation step between two type systems (server objects, JSON over the wire) that don't need translating when both sides are the same React tree.
The "use client" Boundary Is Where You Pay The Cost
Push too much to the server and your app stops being interactive. Push too much to the client and you've recreated the SPA you were trying to escape. The interesting design question with RSC is where to draw the boundary.
The pattern that holds up: build the structural shell on the server, drop in Client Components for the parts that need state, events, or browser APIs. Pass server data down to them as props.
// app/posts/[id]/page.tsx — Server Component
import { db } from '@/lib/db';
import { LikeButton } from '@/components/LikeButton';
import { CommentForm } from '@/components/CommentForm';
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await db.post.findUnique({
where: { id },
include: { _count: { select: { likes: true, comments: true } } },
});
if (!post) return <p>Not found</p>;
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
<LikeButton postId={post.id} initialLikes={post._count.likes} />
<CommentForm postId={post.id} />
</article>
);
}
// components/LikeButton.tsx
'use client';
import { useState } from 'react';
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
return (
<button onClick={() => setLikes((n) => n + 1)}>
Like ({likes})
</button>
);
}
The page is a Server Component. The pieces with state are Client Components. Data flows down through props at the boundary. This is the muscle memory you build over a few weeks of working with the model.
Bundles Get Genuinely Smaller
In a classic SPA, a heavy library imported anywhere in the tree ships to every user. Render a markdown document with marked and dompurify? Both libraries are in the bundle. Format dates with date-fns? In the bundle. Parse user-uploaded YAML with js-yaml? In the bundle, blocking your first paint.
In an RSC app, libraries imported by Server Components stay on the server. The browser receives the rendered HTML and skips the library entirely.
// Server Component — none of this code reaches the browser
import { marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';
export async function PostBody({ markdown }: { markdown: string }) {
const html = DOMPurify.sanitize(await marked.parse(markdown));
return <div className="prose" dangerouslySetInnerHTML={{ __html: html }} />;
}
That component imports two libraries totaling around 100 KB minified. Bundle size impact: zero. The user sees rendered HTML and nothing else. This is the practical reason Server Components matter for performance — the temptation to ship CPU work to the user's phone disappears.
Where The Model Leaks
It's not all clean. A few sharp edges I've hit:
- Serialization boundary. Anything passed from a Server Component to a Client Component must be serializable. Functions, class instances, Symbols, Date objects with non-default behavior — all problematic. Either the framework serializes them with
superjson-style hooks, or you flatten to plain objects at the boundary. - Streaming and Suspense. RSC ships well with
<Suspense>for streaming UI in chunks, but the mental model around fallbacks, errors, and parallel fetches takes a while to internalize. The pattern ofPromise.allinside a Server Component is easy; nesting Suspense boundaries to control what blocks what is harder. - Caching is a Next.js policy, not a React one. Most of the rough edges people complain about —
fetchcache defaults,revalidatePath,unstable_cache— are framework choices, not RSC behavior. Next 15 made the defaults more conservative (uncached by default for fetches and Route Handlers) which most teams welcomed. - Third-party libraries. Many UI libraries assumed everything is a Client Component. A library that calls
useEffectat the module level needs'use client'somewhere or it won't work. The ecosystem has caught up, but you still hit edges with older packages.
The Mental Shift
The hardest thing about adopting Server Components isn't the syntax — it's letting go of the assumption that React is a frontend tool. It is now a tool for describing UI that happens to have two execution environments. The server runs the parts that need data and the client runs the parts that need events. They're stitched together by the same JSX you've written for years.
Once that lands, you stop reaching for useEffect to fetch data. You stop building API routes that exist only to feed your frontend. You stop bundling libraries you only need at render time. The shape of an "average" frontend file changes — fewer hooks, more await, more components that are just async functions returning JSX.
That is what people mean when they say RSCs change architecture. The component tree and the data layer used to be two separate concerns that talked through HTTP. Now they're the same concern, and the network only shows up when you cross the boundary into a browser-side interaction. That's a smaller mental model, and a smaller app.





