React Server Components have been production-stable in Next.js for long enough that the “should I try this?” question is settled. The real question is which client-side patterns are worth replacing — and which ones you should leave alone. After migrating a data-heavy internal dashboard to the App Router, here are five react server components examples that genuinely earned their place.
Pattern 1: The SWR Loader → Async Server Component
Before, every data-fetching component looked like this. A useEffect, a loading state, an error state, and a useSWR call. Three dependencies. A flash of empty UI on every mount.
// Before — client component with SWR
'use client';
import useSWR from 'swr';
export function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useSWR(`/api/users/${userId}`, fetcher);
if (isLoading) return <Skeleton />;
if (error) return <ErrorState />;
return <div>{data.name}</div>;
}
The RSC version:
// After — async server component
async function UserProfile({ userId }: { userId: string }) {
const user = await db.users.findUnique({ where: { id: userId } });
return <div>{user.name}</div>;
}
No loading state. No error boundary at this level. No client JavaScript shipped for the fetch. The data arrives with the HTML.
The thing the docs don't always make clear: you can query your database directly here. Not /api/users/${userId} — the actual ORM call. That's the real win.
Pattern 2: Skeleton UIs → Suspense Boundaries
Before, skeleton UIs were managed with isLoading flags scattered across components. You'd end up with skeletons appearing and disappearing at slightly different times, and a component tree that was genuinely hard to reason about.
// page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<MetricsSkeleton />}>
<Metrics />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
// Metrics.tsx — async server component
async function Metrics() {
const metrics = await fetchMetrics(); // slow query
return <MetricsGrid data={metrics} />;
}
Each Suspense boundary streams independently. Metrics can take 800ms while RecentActivity resolves in 100ms — and the user sees RecentActivity immediately. Coordinating that behaviour with isLoading flags previously took an embarrassing amount of code.
Pattern 3: Waterfall Client Fetches → Parallel Promises
This one is common and painful. A page that fetches a user, then fetches their orders — each request waiting for the previous. On a client component, you either reach for a complex caching strategy or accept the waterfall.
// Before — sequential fetches in useEffect
useEffect(() => {
fetchUser(userId).then(user => {
fetchOrders(user.id).then(setOrders);
});
}, [userId]);
In a server component:
async function OrderPage({ userId }: { userId: string }) {
const [user, orders] = await Promise.all([
db.users.findUnique({ where: { id: userId } }),
db.orders.findMany({ where: { userId } }),
]);
return <OrderView user={user} orders={orders} />;
}
Promise.all in an async server component. No library. No cache invalidation to think about. No race conditions from stale closures.
Pattern 4: Client-Side Auth Redirects → Server Redirects
The old pattern: render the page, check auth in a useEffect, push to /login. The user sees a flash of protected content. Sometimes you'd add a loading state to hide it. Then you'd forget to add it somewhere and get a bug report.
// Before — client redirect
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export function ProtectedPage() {
const { user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !user) router.push('/login');
}, [user, isLoading]);
if (isLoading || !user) return null;
return <Dashboard />;
}
// After — server redirect
import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
export default async function ProtectedPage() {
const session = await getServerSession();
if (!session) redirect('/login');
return <Dashboard user={session.user} />;
}
The redirect happens before any HTML is sent. No flash. No extra loading state. I've seen teams stick with the client pattern purely out of habit — it's worth fixing.
Pattern 5: Prop-Drilling Fetched Data → Co-located Fetch
This one is subtler, but the pattern I find most satisfying to eliminate. Fetch data at the top of the tree, pass it down through four components to where it's actually used. Someone adds a layer between them and forgets to forward the prop. The data stops arriving and nobody knows why for 20 minutes.
// Before — data fetched at top, drilled down
// page.tsx → Layout → Section → UserCard (the thing that actually needs it)
<Layout userData={userData}>
<Section userData={userData}>
<UserCard userData={userData} />
</Section>
</Layout>
With RSCs, UserCard fetches its own data without shipping any client JavaScript:
// UserCard.tsx — server component
async function UserCard({ userId }: { userId: string }) {
const user = await db.users.findUnique({ where: { id: userId } });
return (
<div>
<img src={user.avatarUrl} alt={user.name} />
<span>{user.name}</span>
</div>
);
}
Pass userId (a primitive) instead of the whole user object. Each component owns its data requirements. If multiple components need the same record, wrap the fetch in React's cache() to deduplicate calls within a single render:
import { cache } from 'react';
export const getUser = cache(async (id: string) => {
return db.users.findUnique({ where: { id } });
});
Next.js will also deduplicate fetch() calls with identical URLs automatically — but if you're hitting an ORM directly, cache() is your equivalent.
When RSCs Are the Wrong Tool
Don't refactor everything. Client components still own:
- Interactive state — anything using
useState,useReducer, or event handlers - Browser APIs —
localStorage,window,navigator - Real-time data — WebSockets, polling, optimistic updates
- Third-party client libraries — most charting packages, rich text editors, drag-and-drop
A form with validation and optimistic submission is a client component. A dashboard reading from a database and rendering a table is a server component. The boundary is usually obvious.
One thing worth wiring up before you ship: RSC errors need error.tsx boundaries or they surface as full-page crashes. Sentry handles RSC error tracking well and surfaces the server-side stack trace — the context that purely client-side error tools miss entirely.
The Takeaway
RSCs aren't a rewrite-everything moment. They're a targeted replacement for specific client-heavy patterns. The five above — SWR loaders, skeleton UI choreography, waterfall fetches, client-side auth gates, and prop-drilled data — all have cleaner server-side equivalents that ship less JavaScript and cut UI flicker with minimal ceremony.
If you're on a new project and sorting out the tooling stack before you get to the data layer, the modern React project setup guide without Create React App covers the decisions you'll face first. And if you're deploying on Vercel, RSC streaming and Suspense work out of the box — no extra configuration required.
The “when to use a client component” question starts answering itself after you've shipped a few RSCs. Start with an auth redirect or a simple database read. It clicks fast.