React Server Components and Next.js App Router: The Definitive Guide
React Server Components (RSC) have redefined how we build React applications. Gone is the “everything client-side with useEffect everywhere” paradigm. With Next.js 15 and the App Router, the server becomes a first-class citizen again — and that’s excellent news.
This guide covers everything a senior developer needs to know to master RSC in production.
The Server Components mental model
Before RSC, React had a simple model: all code runs in the browser. Data arrives via API calls, UI builds client-side.
With RSC, React operates on two runtimes:
- Server: Server Components execute on the server, directly access databases, files, APIs, and send serialized HTML to the client. Zero JavaScript sent to the browser for these components.
- Client: Client Components (
'use client') work as before — state, effects, event handlers, browser APIs.
The key: by default, every component in the App Router is a Server Component. Client is the exception, not the rule.
This separation fits into a broader reflection on modern web application architecture, where responsibility isolation is fundamental.
Server vs Client Components
When to use 'use client'
Add 'use client' only when the component needs:
- State:
useState,useReducer - Effects:
useEffect,useLayoutEffect - Event handlers:
onClick,onChange,onSubmit - Browser APIs:
localStorage,window,IntersectionObserver - Custom hooks that use the above
Everything else remains Server Component by default.
Composition patterns
The most powerful pattern: pass Server Components as children of Client Components.
// Layout.tsx (Server Component)
import { Sidebar } from './Sidebar' // Client Component
import { Navigation } from './Navigation' // Server Component
export default function Layout() {
return (
<Sidebar>
<Navigation /> {/* Server Component passed as children */}
</Sidebar>
)
}
// Sidebar.tsx
'use client'
export function Sidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true)
return (
<aside className={isOpen ? 'w-64' : 'w-0'}>
{children} {/* Rendered server-side, injected here */}
</aside>
)
}
Navigation is rendered server-side — zero JS sent — but displays inside Sidebar which manages open state. Best of both worlds.
Data fetching with Server Components
Fetch directly in the component
// app/products/page.tsx
export default async function ProductsPage() {
const products = await db.product.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
})
return (
<ul>
{products.map(p => <ProductCard key={p.id} product={p} />)}
</ul>
)
}
No useEffect. No loading state. No intermediate API route. The component fetches its data directly — on the server — and sends final HTML.
Server Actions for mutations
// app/products/actions.ts
'use server'
export async function createProduct(formData: FormData) {
const product = await db.product.create({
data: {
name: formData.get('name') as string,
price: Number(formData.get('price')),
},
})
revalidatePath('/products')
redirect(`/products/${product.id}`)
}
Server Actions replace API routes for simple mutations. They’re type-safe, progressively enhanced (work without JS), and handle cache revalidation.
Streaming and Suspense
Streaming is RSC’s secret weapon for perceived performance. Instead of waiting for the entire page to be ready, Next.js sends HTML progressively.
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats /> {/* Might take 2s to load */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* Might take 3s to load */}
</Suspense>
</div>
)
}
The <h1> displays immediately. Stats appears as soon as its data is ready. RevenueChart arrives next. The user sees content in milliseconds instead of waiting 3 seconds in front of a spinner.
Performance: RSC’s concrete impact
RSC drastically reduce JavaScript sent to the client:
- Before (classic SPA): all data fetching code, parsing dependencies (date-fns, zod, etc.), and initial rendering are in the client bundle.
- After (RSC): only Client Components and their dependencies are sent. Server Components stay server-side.
On a recent e-commerce project, migrating to RSC reduced the JavaScript bundle by 40% and improved LCP by 1.2 seconds. To go further on performance metrics, see my guide on web performance and Core Web Vitals.
The comparison with the Vue ecosystem is interesting: Vue.js Composition API offers a different but complementary approach to the same composition and reusability problem.
Common pitfalls and solutions
1. Viral “use client”. A 'use client' at the top of a file makes the entire subtree client. Solution: isolate interactive parts in dedicated components.
2. Props serialization. Props passed from Server to Client Components must be serializable (no functions, no classes). Solution: use Server Actions for callbacks.
3. Cache and revalidation. Next.js default cache might serve stale data. Solution: configure revalidate explicitly and use revalidatePath/revalidateTag.
4. Hydration mismatch. Server HTML doesn’t match client render. Solution: avoid random values or Date.now()-based values in Server Components without wrapping in Suspense.
In summary
React Server Components with Next.js 15 means:
- Server by default: less JS, more performance
- Simplified data fetching:
async/awaitdirectly in components - Native streaming: progressive HTML via Suspense
- Server Actions: mutations without API routes
The mental model takes adjustment time, but the result is clear: faster, simpler, and more maintainable applications.
Kevin De Vaubree
Senior Full-Stack Developer