
Six Months With React Server Components: The Honest Assessment
Before I get into this: I'm going to try hard not to be a skeptic or a convert. Both camps are tedious. RSC are a real and significant change to how React works, and the honest take is that they're useful for some things, genuinely confusing for others, and the tooling is still catching up.
Here's what I found.
The Mental Model, Actually Explained
The React docs do a decent job but I'll add my translation.
Before RSC, React components ran in the browser. Always. You might do server-side rendering (SSR) to generate the initial HTML, but the component code ran on the server AND the client your bundle shipped all of it.
With Server Components, some components run only on the server. They never ship to the browser. They can do things browser code can't: read from databases directly, use secrets, access the filesystem. And they produce their output a serialized representation of the React tree, not HTML which the browser reconstructs.
Client Components are what you know from before. They run in the browser. They have state, effects, event handlers.
The confusing part: a Server Component can render Client Components. A Client Component cannot render Server Components (except through slots/props). So the tree is divided: server stuff at the top and edges, client stuff in the interactive parts.
tsx// This file is a Server Component by default in Next.js App Router // app/posts/[id]/page.tsx import { db } from '@/lib/db' import { LikeButton } from '@/components/LikeButton' // Client Component export default async function PostPage({ params }: { params: { id: string } }) { // No useEffect, no fetch with API route — just direct DB access const post = await db.post.findUnique({ where: { id: params.id }, include: { author: true }, }) if (!post) notFound() return ( <article> <h1>{post.title}</h1> <p>By {post.author.name}</p> <div dangerouslySetInnerHTML={{ __html: post.renderedBody }} /> {/* LikeButton is a Client Component — it handles the click */} <LikeButton postId={post.id} initialCount={post.likeCount} /> </article> ) }
tsx// components/LikeButton.tsx 'use client' // This directive makes it a Client Component import { useState } from 'react' export function LikeButton({ postId, initialCount, }: { postId: string initialCount: number }) { const [count, setCount] = useState(initialCount) const [liked, setLiked] = useState(false) const handleLike = async () => { setLiked(true) setCount((c) => c + 1) await fetch(`/api/posts/${postId}/like`, { method: 'POST' }) } return ( <button onClick={handleLike} disabled={liked}> ♥ {count} </button> ) }
What Actually Got Better
Data fetching is less painful. The waterfall problem child needs data, must wait for parent to load and render, then fetch gets flatter. Multiple Server Components can fetch in parallel because they're all running on the server before any HTML goes out.
tsx// These run in parallel — no waterfall async function PostPage({ id }: { id: string }) { const [post, comments, recommendations] = await Promise.all([ getPost(id), getComments(id), getRecommendations(id), ]) return <Layout post={post} comments={comments} recommendations={recommendations} /> }
Bundle size gets real. Markdown parsers, syntax highlighters, date formatting libraries if they only run on the server, they're gone from your bundle. My blog's JavaScript bundle dropped by about 40% when I moved the markdown processing to a Server Component.
Colocation feels right. Having the data fetching and the rendering in the same file, with no API layer in between for internal data, is genuinely nice. There's less indirection.
Where I Got Burned
The 'use client' Boundary Propagates
When you mark a component 'use client', all its imports become client-side too. This is the rule that catches you off guard.
I had a utility library that I marked as a client component because it had one function that used useState. Every component that imported it became a client component. I had to split the library to keep the pure functions server-side.
tsx// Before: one file, one 'use client' contaminates everything 'use client' export function useToggle() { ... } // needs client export function formatDate() { ... } // doesn't need client, but is now client export function parseMarkdown() { ... } // same now in the bundle // After: split the file // utils/client.ts 'use client' export function useToggle() { ... } // utils/shared.ts (no directive — can be used anywhere) export function formatDate() { ... } export function parseMarkdown() { ... }
Serialization Limits Are a Gotcha
Props passed from Server to Client Components must be serializable. No functions, no class instances, no Date objects (they become strings), no undefined values in arrays.
tsx// This breaks at runtime functions aren't serializable <ClientComponent onSomething={() => doThing()} // ❌ Can't pass a function from server to client /> // So does this <ClientComponent date={new Date()} // ❌ Date becomes a string on the other side />
The Date one in particular caught me because TypeScript doesn't tell you the prop type says Date, but what the client receives is a string. You find out when .toLocaleDateString() throws.
My workaround: convert Dates to ISO strings or timestamps explicitly at the boundary:
tsx// Be explicit about the serialization <ClientComponent dateIso={post.createdAt.toISOString()} />
Testing Is Behind
@testing-library/react doesn't support Server Components yet in any practical way. You either skip testing Server Components (bad), test them as regular async functions (fine for the logic, misses the React parts), or wait for the ecosystem to catch up.
For now, I test Server Components by extracting their data-fetching logic into plain async functions and testing those directly, then writing integration tests for the combined behavior:
typescript// Extract the data logic to a testable function export async function getPostData(id: string) { const post = await db.post.findUnique({ where: { id } }) if (!post) return null return { ...post, formattedDate: formatDate(post.createdAt) } } // In tests: test('returns null for missing post', async () => { const result = await getPostData('nonexistent-id') expect(result).toBeNull() })
The Suspense Integration
Server Components work with Suspense for progressive rendering. This is where things get elegant:
tsximport { Suspense } from 'react' export default function PostPage({ params }: { params: { id: string } }) { return ( <div> <Suspense fallback={<PostSkeleton />}> <PostContent id={params.id} /> </Suspense> <Suspense fallback={<CommentsSkeleton />}> <Comments id={params.id} /> </Suspense> </div> ) }
The shell renders immediately. PostContent and Comments fetch in parallel. Whichever finishes first streams its content. The page feels fast even if individual pieces are slow.
This used to require careful orchestration with loading states and APIs. Now it's two Suspense boundaries.
Would I Use It Again?
Yes, with conditions.
For content-heavy apps blogs, documentation sites, news sites the bundle size wins and data fetching simplicity are real gains. RSC is the right default here.
For highly interactive apps dashboards, editors, anything with lots of real-time state most of your components end up as client components anyway, and RSC adds mental overhead without much benefit. You could use it selectively for the static parts, but you're managing two mental models.
For anything with a non-Next.js backend, the story is still rough. Next.js App Router is the primary RSC environment. Other frameworks have varying support.
The mental model does click eventually. But "eventually" took me about three weeks before I stopped accidentally hitting the serialization limits and the boundary propagation gotchas. Budget that time.