React Server Components: Mental Models, Patterns, और Pitfalls
React Server Components की वो practical guide जो मेरी शुरुआत में होती तो अच्छा होता। Mental models, real patterns, boundary problem, और वो गलतियां जो मैंने कीं ताकि आपको न करनी पड़ें।
मैंने React Server Components के साथ पहले तीन महीने ये सोचते हुए बिताए कि मुझे इन्हें समझ आ गया है। मैंने RFCs पढ़े, conference talks देखे, कुछ demo apps बनाए। मैं आत्मविश्वास से लगभग हर बात में गलत था।
समस्या ये नहीं है कि RSC जटिल है। बात ये है कि इसका mental model वाकई में React में पहले जो कुछ भी किया है उससे अलग है, और हर कोई — मैं भी — इसे पुराने ढांचे में फिट करने की कोशिश करता है। "यह SSR जैसा है।" नहीं है। "यह PHP जैसा है।" थोड़ा करीब, लेकिन नहीं। "ये बस components हैं जो server पर चलते हैं।" तकनीकी रूप से सही, व्यावहारिक रूप से बेकार।
जो आगे है वो सब कुछ है जो मुझे वास्तव में जानना चाहिए था, उस तरह लिखा गया जैसे काश किसी ने मुझे समझाया होता। Theoretical version नहीं। वो version जहां आप रात 11 बजे serialization error को घूर रहे हैं और समझना चाहते हैं क्यों।
वो Mental Model जो वाकई काम करता है#
एक पल के लिए React rendering के बारे में जो कुछ भी जानते हैं, भूल जाइए। ये रही नई तस्वीर।
Traditional React (client-side) में, आपका पूरा component tree JavaScript के रूप में browser को भेजा जाता है। Browser इसे download करता है, parse करता है, execute करता है, और result render करता है। हर component — चाहे वो 200-line का interactive form हो या text का एक static paragraph — एक ही pipeline से गुजरता है।
React Server Components इसे दो दुनियाओं में बांटते हैं:
Server Components server पर चलते हैं। ये एक बार execute होते हैं, अपना output produce करते हैं, और result client को भेजते हैं — code नहीं। Browser कभी component function नहीं देखता, इसकी dependencies कभी download नहीं करता, इसे कभी re-render नहीं करता।
Client Components traditional React की तरह काम करते हैं। ये browser को भेजे जाते हैं, hydrate होते हैं, state maintain करते हैं, events handle करते हैं। ये वही React है जो आप पहले से जानते हैं।
वो मुख्य समझ जो मुझे शर्मनाक रूप से देर से आई: Server Components default हैं। Next.js App Router में, हर component एक Server Component है जब तक आप इसे explicitly "use client" से client में opt-in नहीं करते। ये वो है जो हम करते आए हैं उसके विपरीत है, और ये composition के बारे में सोचने का तरीका बदलता है।
Rendering Waterfall#
यहां बताया गया है कि जब कोई user page request करता है तो वास्तव में क्या होता है:
1. Request server पर आती है
2. Server top-down Server Components execute करता है
3. जब Server Component "use client" boundary पर पहुंचता है,
तो रुक जाता है — वो subtree client पर render होगा
4. Server Components RSC Payload produce करते हैं (एक special format)
5. RSC Payload client को stream होता है
6. Client, Client Components render करता है, उन्हें
server-rendered tree में जोड़ता है
7. Hydration Client Components को interactive बनाता है
Step 4 वहां है जहां सबसे ज्यादा confusion रहता है। RSC Payload HTML नहीं है। ये एक special streaming format है जो component tree को describe करता है — server ने क्या render किया, client को कहां संभालना है, और boundary के पार कौन से props pass करने हैं।
ये लगभग ऐसा दिखता है (simplified):
M1:{"id":"./src/components/Counter.tsx","chunks":["272:static/chunks/272.js"],"name":"Counter"}
S0:"$Sreact.suspense"
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome"}],["$","$L1",null,{"initialCount":0}]]}]
आपको ये format याद रखने की जरूरत नहीं है। लेकिन ये समझना कि ये exist करता है — कि server और client के बीच एक serialization layer है — आपके debugging के घंटे बचाएगा। हर बार जब आपको "Props must be serializable" error मिलता है, तो इसलिए क्योंकि जो आप pass कर रहे हैं वो इस translation से गुजर नहीं सकता।
"Server पर चलता है" का असल मतलब#
जब मैं कहता हूं कि Server Component "server पर चलता है," तो मेरा मतलब शाब्दिक है। Component function Node.js (या Edge runtime) में execute होता है। इसका मतलब आप ये कर सकते हैं:
// app/dashboard/page.tsx — ये default रूप से Server Component है
import { db } from "@/lib/database";
import { headers } from "next/headers";
export default async function DashboardPage() {
const headerList = await headers();
const userId = headerList.get("x-user-id");
// Direct database query. कोई API route जरूरी नहीं।
const user = await db.user.findUnique({
where: { id: userId },
});
const recentOrders = await db.order.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 10,
});
return (
<div>
<h1>Welcome back, {user.name}</h1>
<OrderList orders={recentOrders} />
</div>
);
}कोई useEffect नहीं। कोई loading state management नहीं। चीजों को जोड़ने के लिए कोई API route नहीं। Component ही data layer है। ये RSC की सबसे बड़ी जीत है और ये वो चीज है जो शुरू में सबसे ज्यादा असहज लगी, क्योंकि मैं सोचता रहा "लेकिन separation कहां है?"
Separation "use client" boundary है। उसके ऊपर सब कुछ server है। उसके नीचे सब कुछ client है। वही आपकी architecture है।
Server/Client Boundary#
यहीं पर ज्यादातर लोगों की समझ टूटती है, और जहां मैंने पहले कुछ महीनों में अपना ज्यादातर debugging समय बिताया।
"use client" Directive#
किसी file के top पर "use client" directive उस file से export होने वाली हर चीज को Client Component के रूप में mark करता है। ये एक module-level annotation है, component-level नहीं।
// src/components/Counter.tsx
"use client";
import { useState } from "react";
// यह पूरी file अब "client territory" है
export function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// ये भी Client Component है क्योंकि ये उसी file में है
export function ResetButton({ onReset }: { onReset: () => void }) {
return <button onClick={onReset}>Reset</button>;
}सामान्य गलती: barrel file (index.ts) में "use client" लगाना जो सब कुछ re-export करती है। बधाई हो, आपने अभी अपनी पूरी component library client-side बना दी। मैंने teams को इस तरह गलती से 200KB JavaScript ship करते देखा है।
Boundary के पार क्या जाता है#
यहां वो rule है जो आपको बचाएगा: server-client boundary के पार जो कुछ भी जाता है वो JSON में serializable होना चाहिए।
क्या serializable है:
- Strings, numbers, booleans, null, undefined
- Arrays और plain objects (serializable values वाले)
- Dates (ISO strings के रूप में serialized)
- Server Components (JSX के रूप में — इस पर बाद में आएंगे)
- FormData
- Typed arrays, ArrayBuffer
क्या serializable नहीं है:
- Functions (event handlers सहित)
- Classes (custom classes के instances)
- Symbols
- DOM nodes
- Streams (ज्यादातर contexts में)
इसका मतलब आप ये नहीं कर सकते:
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList"; // Client Component
export default async function Page() {
const items = await getItems();
return (
<ItemList
items={items}
// ERROR: Functions serializable नहीं हैं
onItemClick={(id) => console.log(id)}
// ERROR: Class instances serializable नहीं हैं
formatter={new Intl.NumberFormat("en-US")}
/>
);
}fix ये नहीं है कि page को Client Component बना दिया जाए। Fix ये है कि interactivity को नीचे push करें और data fetching को ऊपर:
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
export default async function Page() {
const items = await getItems();
// सिर्फ serializable data pass करें
return <ItemList items={items} locale="en-US" />;
}// src/components/ItemList.tsx (Client Component)
"use client";
import { useState, useMemo } from "react";
interface Item {
id: string;
name: string;
price: number;
}
export function ItemList({ items, locale }: { items: Item[]; locale: string }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// Client side पर formatter बनाएं
const formatter = useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
[locale]
);
return (
<ul>
{items.map((item) => (
<li
key={item.id}
onClick={() => setSelectedId(item.id)}
className={selectedId === item.id ? "selected" : ""}
>
{item.name} — {formatter.format(item.price)}
</li>
))}
</ul>
);
}"Island" भ्रम#
मैंने शुरू में Client Components को "islands" के रूप में सोचा — server-rendered content के समुद्र में छोटे interactive हिस्से। ये आंशिक रूप से सही है लेकिन एक महत्वपूर्ण detail miss करता है: एक Client Component, Server Components को render कर सकता है अगर उन्हें children या props के रूप में pass किया जाए।
इसका मतलब boundary एक कठोर दीवार नहीं है। ये एक membrane की तरह है। Server-rendered content children pattern के माध्यम से Client Components से होकर बह सकता है। इस पर हम composition section में गहराई से बात करेंगे।
Data Fetching Patterns#
RSC data fetching को मूल रूप से बदलता है। अब render time पर ज्ञात data के लिए useEffect + useState + loading states नहीं चाहिए। लेकिन नए patterns की अपनी gotchas हैं।
Basic Fetch with Caching#
Server Component में, आप बस fetch करते हैं। Next.js global fetch को extend करता है और caching जोड़ता है:
// app/products/page.tsx
export default async function ProductsPage() {
// Default रूप से cached — same URL cached result लौटाता है
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return (
<div>
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}Caching behavior को explicitly control करें:
// हर 60 seconds में revalidate (ISR-जैसा behavior)
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
// कोई caching नहीं — हमेशा fresh data
const res = await fetch("https://api.example.com/user/profile", {
cache: "no-store",
});
// On-demand revalidation के लिए tags के साथ cache
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});फिर आप Server Action से tag द्वारा revalidate कर सकते हैं:
"use server";
import { revalidateTag } from "next/cache";
export async function refreshProducts() {
revalidateTag("products");
}Parallel Data Fetching#
सबसे आम performance गलती जो मुझे दिखती है: parallel काम कर सकता होने पर sequential data fetching।
खराब — sequential (waterfalls):
// app/dashboard/page.tsx — ऐसा मत करें
export default async function Dashboard() {
const user = await getUser(); // 200ms
const orders = await getOrders(); // 300ms
const notifications = await getNotifications(); // 150ms
// Total: 650ms — हर एक पिछले का इंतजार करता है
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}अच्छा — parallel:
// app/dashboard/page.tsx — ऐसा करें
export default async function Dashboard() {
// तीनों एक साथ fire होते हैं
const [user, orders, notifications] = await Promise.all([
getUser(), // 200ms
getOrders(), // 300ms (parallel में चलता है)
getNotifications(), // 150ms (parallel में चलता है)
]);
// Total: ~300ms — सबसे धीमी request द्वारा सीमित
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}और भी बेहतर — independent Suspense boundaries के साथ parallel:
// app/dashboard/page.tsx — सबसे अच्छा
import { Suspense } from "react";
export default function Dashboard() {
// ध्यान दें: ये component async नहीं है — ये children को delegate करता है
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<OrderListSkeleton />}>
<OrderList />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<Notifications />
</Suspense>
</div>
);
}
// हर component अपना data खुद fetch करता है
async function UserInfo() {
const user = await getUser();
return <div>{user.name}</div>;
}
async function OrderList() {
const orders = await getOrders();
return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
async function Notifications() {
const notifications = await getNotifications();
return <span>({notifications.length})</span>;
}ये आखिरी pattern सबसे powerful है क्योंकि हर section independently load होता है। User content उपलब्ध होने पर देखता है, सब-कुछ-या-कुछ-नहीं नहीं। तेज sections धीमे sections का इंतजार नहीं करते।
Request Deduplication#
Next.js एक ही render pass के दौरान same URL और options वाली fetch calls को automatically deduplicate करता है। इसका मतलब आपको redundant requests से बचने के लिए data fetching को hoist करने की जरूरत नहीं है:
// ये दोनों components same URL fetch कर सकते हैं
// और Next.js सिर्फ एक actual HTTP request करेगा
async function Header() {
const user = await fetch("/api/user").then(r => r.json());
return <nav>Welcome, {user.name}</nav>;
}
async function Sidebar() {
// Same URL — automatically deduped, दूसरी request नहीं
const user = await fetch("/api/user").then(r => r.json());
return <aside>Role: {user.role}</aside>;
}महत्वपूर्ण चेतावनी: ये सिर्फ fetch के साथ काम करता है। अगर आप ORM या database client directly use कर रहे हैं, तो आपको React का cache() function use करना होगा:
import { cache } from "react";
import { db } from "@/lib/database";
// अपने data function को cache() से wrap करें
// अब same render में multiple calls = एक actual query
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
});cache() एक single server request की lifetime के लिए deduplicate करता है। ये persistent cache नहीं है — ये per-request memoization है। Request पूरी होने के बाद, cached values garbage collect हो जाते हैं।
Component Composition Patterns#
यहीं RSC वाकई elegant हो जाता है, एक बार जब आप patterns समझ लेते हैं। और वाकई confusing, जब तक नहीं समझते।
"Children as a Hole" Pattern#
ये RSC में सबसे महत्वपूर्ण composition pattern है और मुझे इसकी पूरी तरह कद्र करने में हफ्ते लगे। समस्या ये है: आपके पास एक Client Component है जो कुछ layout या interactivity प्रदान करता है, और आप उसके अंदर Server Components render करना चाहते हैं।
आप Client Component file में Server Component import नहीं कर सकते। जैसे ही आप "use client" जोड़ते हैं, उस module में सब कुछ client-side हो जाता है। लेकिन आप Server Components को children के रूप में pass कर सकते हैं:
// src/components/Sidebar.tsx — Client Component
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<aside className={isOpen ? "w-64" : "w-0"}>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? "Close" : "Open"}
</button>
{isOpen && (
<div className="sidebar-content">
{/* ये children Server Components हो सकते हैं! */}
{children}
</div>
)}
</aside>
);
}// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // Server Component
import { UserProfile } from "@/components/UserProfile"; // Server Component
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar>
{/* ये Server Components हैं, Client Component से होकर pass हो रहे हैं */}
<UserProfile />
<NavigationLinks />
</Sidebar>
<main>{children}</main>
</div>
);
}ये क्यों काम करता है? क्योंकि Server Components (UserProfile, NavigationLinks) पहले server पर render होते हैं, फिर उनका output (RSC payload) children के रूप में Client Component को pass किया जाता है। Client Component को कभी जानने की जरूरत नहीं कि वो Server Components थे — उसे बस pre-rendered React nodes मिलते हैं।
children को Client Component में एक "hole" समझें जहां server-rendered content बह सकता है।
Server Components को Props के रूप में Pass करना#
Children pattern किसी भी prop पर generalize होता है जो React.ReactNode accept करता है:
// src/components/TabLayout.tsx — Client Component
"use client";
import { useState } from "react";
interface TabLayoutProps {
tabs: { label: string; content: React.ReactNode }[];
}
export function TabLayout({ tabs }: TabLayoutProps) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div className="tab-bar" role="tablist">
{tabs.map((tab, i) => (
<button
key={i}
role="tab"
aria-selected={i === activeTab}
onClick={() => setActiveTab(i)}
className={i === activeTab ? "active" : ""}
>
{tab.label}
</button>
))}
</div>
<div role="tabpanel">{tabs[activeTab].content}</div>
</div>
);
}// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings"; // Server Component — data fetch कर सकता है
import { BillingSettings } from "./BillingSettings"; // Server Component — data fetch कर सकता है
import { SecuritySettings } from "./SecuritySettings"; // Server Component — data fetch कर सकता है
export default function SettingsPage() {
return (
<TabLayout
tabs={[
{ label: "Profile", content: <ProfileSettings /> },
{ label: "Billing", content: <BillingSettings /> },
{ label: "Security", content: <SecuritySettings /> },
]}
/>
);
}हर settings component एक async Server Component हो सकता है जो अपना data खुद fetch करता है। Client Component (TabLayout) बस tab switching handle करता है। ये एक अविश्वसनीय रूप से powerful pattern है।
Async Server Components#
Server Components async हो सकते हैं। ये बहुत बड़ी बात है क्योंकि इसका मतलब data fetching rendering के दौरान होती है, side effect के रूप में नहीं:
// ये valid और खूबसूरत है
async function BlogPost({ slug }: { slug: string }) {
const post = await db.post.findUnique({ where: { slug } });
if (!post) return notFound();
const author = await db.user.findUnique({ where: { id: post.authorId } });
return (
<article>
<h1>{post.title}</h1>
<p>By {author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</article>
);
}Client Components async नहीं हो सकते। अगर आप "use client" component को async बनाने की कोशिश करते हैं, तो React error throw करेगा। ये एक कठोर constraint है।
Suspense Boundaries: Streaming Primitive#
Suspense वो है जिससे आपको RSC में streaming मिलती है। Suspense boundaries के बिना, पूरा page सबसे धीमे async component का इंतजार करता है। इनके साथ, हर section independently stream होता है:
// app/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { ProductGrid } from "@/components/ProductGrid";
import { ReviewCarousel } from "@/components/ReviewCarousel";
import { RecommendationEngine } from "@/components/RecommendationEngine";
export default function HomePage() {
return (
<main>
{/* Static — तुरंत render होता है */}
<HeroSection />
{/* तेज data — जल्दी दिखता है */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
{/* मध्यम speed — तैयार होने पर दिखता है */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewCarousel />
</Suspense>
{/* धीमा (ML-powered) — सबसे बाद दिखता है, बाकी को block नहीं करता */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendationEngine />
</Suspense>
</main>
);
}User HeroSection तुरंत देखता है, फिर ProductGrid stream होकर आता है, फिर reviews, फिर recommendations। हर Suspense boundary एक independent streaming point है।
Suspense boundaries को nest करना भी valid और उपयोगी है:
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</Dashboard>
</Suspense>अगर Dashboard तेज है लेकिन RevenueChart धीमा है, तो outer Suspense पहले resolve होता है (dashboard shell दिखाता है), और chart के लिए inner Suspense बाद में resolve होता है।
Suspense के साथ Error Boundaries#
Resilient UIs के लिए Suspense को error.tsx के साथ जोड़ें:
// app/dashboard/error.tsx — Client Component (होना ही चाहिए)
"use client";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-8 text-center">
<h2>Dashboard लोड करने में कुछ गड़बड़ हुई</h2>
<p className="text-gray-500">{error.message}</p>
<button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
फिर से कोशिश करें
</button>
</div>
);
}error.tsx file automatically corresponding route segment को React Error Boundary में wrap करती है। अगर उस segment में कोई Server Component throw करता है, तो पूरे page को crash करने की बजाय error UI दिखता है।
कब कौन सा use करें: Decision Tree#
RSC के साथ कई production apps बनाने के बाद, मैं एक स्पष्ट decision framework पर आया हूं। यहां वो actual thought process है जिससे मैं हर component के लिए गुजरता हूं:
Server Components से शुरू करें (Default)#
हर component Server Component होना चाहिए जब तक कोई specific कारण न हो कि वो नहीं हो सकता। ये सबसे महत्वपूर्ण rule है।
इसे Client Component बनाएं जब:#
1. ये browser-only APIs use करता है
"use client";
// window, document, navigator, localStorage, आदि
function GeoLocation() {
const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
useEffect(() => {
navigator.geolocation.getCurrentPosition(
(pos) => setCoords(pos.coords)
);
}, []);
return coords ? <p>Lat: {coords.latitude}</p> : <p>Location लोड हो रहा है...</p>;
}2. ये React hooks use करता है जिन्हें state या effects चाहिए
useState, useEffect, useReducer, useRef (mutable refs के लिए), useContext — इनमें से कोई भी "use client" की जरूरत है।
"use client";
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// ये component Client Component होना ही चाहिए क्योंकि ये
// useState use करता है और user input manage करता है
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="खोजें..."
/>
);
}3. ये event handlers attach करता है
onClick, onChange, onSubmit, onMouseEnter — कोई भी interactive behavior client-side है।
4. ये client-side libraries use करता है
Framer Motion, React Hook Form, Zustand, React Query (client-side fetching के लिए), कोई भी charting library जो canvas या SVG पर interactively render करती है।
इसे Server Component रखें जब:#
- ये सिर्फ data display करता है (कोई user interaction नहीं)
- ये database या API से data fetch करता है
- ये backend resources access करता है (file system, secrets वाले env variables)
- ये बड़ी dependencies import करता है जिनकी client को जरूरत नहीं (markdown parsers, syntax highlighters, formatting के लिए date libraries)
- ये static या semi-static content render करता है
Practice में Real-World Decision#
यहां एक concrete उदाहरण है। मैं एक product page बना रहा हूं:
ProductPage (Server)
├── ProductBreadcrumbs (Server) — static navigation, कोई interactivity नहीं
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│ ├── ProductTitle (Server) — सिर्फ text
│ ├── ProductPrice (Server) — formatted number, कोई interaction नहीं
│ └── AddToCartButton (Client) — onClick, cart state manage करता है
├── ProductDescription (Server) — rendered markdown
├── Suspense
│ └── RelatedProducts (Server) — async data fetch, धीमा API
└── Suspense
└── ProductReviews (Server)
└── ReviewForm (Client) — validation के साथ form
Pattern पर ध्यान दें: page shell और data-heavy parts Server Components हैं। Interactive islands (ImageGallery, AddToCartButton, ReviewForm) Client Components हैं। धीमे sections (RelatedProducts, ProductReviews) Suspense में wrapped हैं।
ये theoretical नहीं है। मेरे component trees वास्तव में ऐसे ही दिखते हैं।
सामान्य गलतियां (मैंने ये सब की हैं)#
गलती 1: सब कुछ Client Component बनाना#
Pages Router या Create React App से migrate करते समय सबसे आसान रास्ता हर चीज पर "use client" चिपका देना है। ये काम करता है! कुछ नहीं टूटता! आप अपना पूरा component tree JavaScript के रूप में भी ship कर रहे हैं और RSC का कोई फायदा नहीं मिल रहा।
मैंने ऐसे codebases देखे हैं जहां root layout पर "use client" है। उस point पर आप literally extra steps के साथ client-side React app चला रहे हैं।
Fix: Server Components से शुरू करें। "use client" तभी जोड़ें जब compiler बताए कि जरूरी है (क्योंकि आपने hook या event handler use किया)। "use client" को tree में जितना हो सके नीचे push करें।
गलती 2: Boundary के Through Prop Drilling#
// खराब: Server Component में data fetch करना, फिर इसे
// multiple Client Components से pass करना
// app/page.tsx (Server)
export default async function Page() {
const user = await getUser();
const settings = await getSettings();
const theme = await getTheme();
return (
<ClientShell user={user} settings={settings} theme={theme}>
<ClientContent user={user} settings={settings}>
<ClientWidget user={user} />
</ClientContent>
</ClientShell>
);
}हर data जो आप boundary के पार pass करते हैं वो RSC payload में serialize होता है। Same object पांच बार pass करें? वो payload में पांच बार है। मैंने RSC payloads को इस वजह से megabytes तक बढ़ते देखा है।
Fix: Composition use करें। Data को props के रूप में pass करने की बजाय Server Components को children के रूप में pass करें:
// अच्छा: Server Components अपना data खुद fetch करते हैं, children के रूप में pass होते हैं
// app/page.tsx (Server)
export default function Page() {
return (
<ClientShell>
<UserInfo /> {/* Server Component — अपना data खुद fetch करता है */}
<Settings /> {/* Server Component — अपना data खुद fetch करता है */}
<ClientWidget>
<UserAvatar /> {/* Server Component — अपना data खुद fetch करता है */}
</ClientWidget>
</ClientShell>
);
}गलती 3: Suspense Use न करना#
Suspense के बिना, आपके page का Time to First Byte (TTFB) आपके सबसे धीमे data fetch द्वारा सीमित है। मेरे पास एक dashboard page था जो load होने में 4 seconds लेता था क्योंकि एक analytics query धीमी थी, भले ही बाकी page data 200ms में तैयार था।
// खराब: सब कुछ सबके इंतजार में
export default async function Dashboard() {
const stats = await getStats(); // 200ms
const chart = await getChartData(); // 300ms
const analytics = await getAnalytics(); // 4000ms ← सब कुछ block करता है
return (
<div>
<Stats data={stats} />
<Chart data={chart} />
<Analytics data={analytics} />
</div>
);
}// अच्छा: analytics independently load होता है
export default function Dashboard() {
return (
<div>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* 4s लेता है लेकिन बाकी को block नहीं करता */}
</Suspense>
</div>
);
}गलती 4: Runtime पर Serialization Errors#
ये विशेष रूप से दर्दनाक है क्योंकि आप अक्सर इसे production तक नहीं पकड़ते। आप boundary के पार कुछ non-serializable pass करते हैं और एक cryptic error मिलता है:
Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.
सामान्य अपराधी:
- Date objects pass करना (
toISOString()use करें) - Map या Set pass करना (arrays/objects में convert करें)
- ORMs से class instances pass करना (
.toJSON()use करें या plain objects में spread करें) - Functions pass करना (logic को Client Component में move करें या Server Actions use करें)
Decimalfields वाले Prisma model results pass करना (numberयाstringमें convert करें)
// खराब
const user = await prisma.user.findUnique({ where: { id } });
// user में non-serializable fields हो सकते हैं (Decimal, BigInt, आदि)
return <ClientProfile user={user} />;
// अच्छा
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
id: user.id,
name: user.name,
email: user.email,
balance: user.balance.toNumber(), // Decimal → number
createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;गलती 5: हर चीज के लिए Context Use करना#
useContext सिर्फ Client Components में काम करता है। अगर आप Server Component में React context use करने की कोशिश करते हैं, तो काम नहीं करेगा। मैंने लोगों को सिर्फ theme context use करने के लिए पूरा app Client Component बनाते देखा है।
Fix: Themes और अन्य global state के लिए, server side पर set CSS variables use करें, या cookies() / headers() functions use करें:
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
<html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
<body>{children}</body>
</html>
);
}वाकई client-side state (auth tokens, shopping carts, real-time data) के लिए, उपयुक्त level पर एक thin Client Component provider बनाएं — root पर नहीं:
// src/providers/CartProvider.tsx
"use client";
import { createContext, useContext, useState } from "react";
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
return (
<CartContext.Provider value={{ items, setItems }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart CartProvider के अंदर use होना चाहिए");
return context;
}// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
// CartProvider एक Client Component है, लेकिन children server content के रूप में बहते हैं
return <CartProvider>{children}</CartProvider>;
}गलती 6: Bundle Size Impact को नजरअंदाज करना#
RSC की सबसे बड़ी जीत में से एक ये है कि Server Component code कभी client को ship नहीं होता। लेकिन आपको इसके बारे में सक्रिय रूप से सोचना होगा। अगर आपके पास एक component है जो 50KB markdown parser use करता है और सिर्फ rendered content display करता है — वो Server Component होना चाहिए। Parser server पर रहता है, और सिर्फ HTML output client को जाता है।
// Server Component — marked server पर रहता है
import { marked } from "marked"; // 50KB library — कभी client को ship नहीं होती
async function BlogContent({ slug }: { slug: string }) {
const post = await getPost(slug);
const html = marked(post.markdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}अगर आप इसे Client Component बनाते, तो marked browser को ship होता। बिना किसी वजह के। User 50KB JavaScript download करता सिर्फ ऐसा content render करने के लिए जो शुरू से HTML हो सकता था।
अपने bundle को @next/bundle-analyzer से check करें। Results आपको चौंका सकते हैं।
Caching Strategy#
Next.js 15+ में caching पुराने versions की तुलना में काफी simplified हो गई है, लेकिन अभी भी अलग-अलग layers समझनी हैं।
cache() Function (React)#
React का cache() per-request deduplication के लिए है, persistent caching नहीं:
import { cache } from "react";
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session) return null;
return db.user.findUnique({ where: { id: session.userId } });
});
// एक single request के दौरान अपने component tree में कहीं भी इसे call करें।
// सिर्फ एक actual database query execute होगी।ये single server request तक scoped है। Request पूरी होने पर, cached value चली जाती है। ये memoization है, caching नहीं।
unstable_cache (Next.js)#
Requests के across persistent caching के लिए, unstable_cache use करें (नाम हमेशा से "unstable" रहा है, लेकिन production में ठीक काम करता है):
import { unstable_cache } from "next/cache";
const getCachedProducts = unstable_cache(
async (categoryId: string) => {
return db.product.findMany({
where: { categoryId },
include: { images: true },
});
},
["products-by-category"], // cache key prefix
{
revalidate: 3600, // हर घंटे revalidate
tags: ["products"], // on-demand revalidation के लिए
}
);
// Server Component में usage
async function ProductGrid({ categoryId }: { categoryId: string }) {
const products = await getCachedProducts(categoryId);
return <Grid items={products} />;
}Invalidate करने के लिए:
"use server";
import { revalidateTag } from "next/cache";
export async function onProductUpdate() {
revalidateTag("products");
}Static vs Dynamic Rendering#
Next.js तय करता है कि route static है या dynamic, इस आधार पर कि आप उसमें क्या use करते हैं:
Static (build time पर rendered, cached):
- कोई dynamic functions नहीं (
cookies(),headers(),searchParams) - सभी
fetchcalls में caching enabled है - कोई
export const dynamic = "force-dynamic"नहीं
Dynamic (हर request पर rendered):
cookies(),headers(), याsearchParamsuse करता हैcache: "no-store"के साथfetchuse करता हैexport const dynamic = "force-dynamic"हैnext/serverसेconnection()याafter()use करता है
आप check कर सकते हैं कौन से routes static हैं vs dynamic, next build चलाकर — ये नीचे एक legend दिखाता है:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 92 kB
├ ○ /about 1.1 kB 88 kB
├ ● /blog/[slug] 2.3 kB 89 kB
├ λ /dashboard 4.1 kB 91 kB
└ λ /api/products 0 B 87 kB
○ Static ● SSG λ Dynamic
Caching Hierarchy#
Caching को layers में सोचें:
1. React cache() — per-request, in-memory, automatic dedup
2. fetch() cache — cross-request, GET requests के लिए automatic
3. unstable_cache() — cross-request, non-fetch operations के लिए
4. Full Route Cache — build/revalidation time पर rendered HTML cached
5. Router Cache (client) — browser में visited routes का cache
हर layer एक अलग purpose serve करती है। आपको हमेशा सभी की जरूरत नहीं, लेकिन ये समझना कि कौन सी active है "मेरा data क्यों update नहीं हो रहा?" issues debug करने में मदद करता है।
Real-World Caching Strategy#
यहां बताया गया है कि मैं production में वास्तव में क्या करता हूं:
// lib/data/products.ts
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
// Per-request dedup: एक render में इसे multiple बार call करें,
// सिर्फ एक DB query चलती है
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { category: true, images: true },
});
});
// Cross-request cache: results requests के across persist करते हैं,
// हर 5 minutes या on-demand tag से revalidate
export const getPopularProducts = unstable_cache(
async () => {
return db.product.findMany({
orderBy: { salesCount: "desc" },
take: 20,
include: { images: true },
});
},
["popular-products"],
{ revalidate: 300, tags: ["products"] }
);
// कोई caching नहीं: हमेशा fresh (user-specific data के लिए)
export const getUserCart = cache(async (userId: string) => {
// cache() यहां सिर्फ per-request dedup के लिए है, persistence नहीं
return db.cart.findUnique({
where: { userId },
include: { items: { include: { product: true } } },
});
});अंगूठे का नियम: public data जो कम बदलता है उसे unstable_cache मिलता है। User-specific data को सिर्फ dedup के लिए cache() मिलता है। Real-time data को कोई caching नहीं।
Server Actions: वापसी का पुल#
Server Actions अपना अलग section deserve करते हैं क्योंकि ये RSC की कहानी पूरी करते हैं। ये वो तरीका है जिससे Client Components API routes के बिना server से वापस communicate करते हैं।
// app/actions/newsletter.ts
"use server";
import { db } from "@/lib/database";
import { z } from "zod";
const emailSchema = z.string().email();
export async function subscribeToNewsletter(formData: FormData) {
const email = formData.get("email");
const result = emailSchema.safeParse(email);
if (!result.success) {
return { error: "अमान्य email पता" };
}
try {
await db.subscriber.create({
data: { email: result.data },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { error: "पहले से subscribed है" };
}
return { error: "कुछ गड़बड़ हुई" };
}
}// src/components/NewsletterForm.tsx
"use client";
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
export function NewsletterForm() {
const [state, formAction, isPending] = useActionState(
async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
return subscribeToNewsletter(formData);
},
null
);
return (
<form action={formAction}>
<input
type="email"
name="email"
required
placeholder="you@example.com"
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? "Subscribe हो रहा है..." : "Subscribe"}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">Subscribe हो गया!</p>}
</form>
);
}Server Actions "data कैसे mutate करें?" का RSC world में जवाब हैं। ये form submissions, mutations, और side effects के लिए ज्यादातर API routes की जगह लेते हैं।
Server Actions के मुख्य नियम:
- हमेशा input validate करें (function client से callable है — इसे API endpoint की तरह treat करें)
- हमेशा serializable data return करें
- ये server पर चलते हैं, इसलिए आप databases, file systems, secrets access कर सकते हैं
- ये mutations के बाद cached data update करने के लिए
revalidatePath()याrevalidateTag()call कर सकते हैं
Migration Patterns#
अगर आपके पास existing React app है (Pages Router, Create React App, Vite), तो RSC में move करना rewrite नहीं होना चाहिए। यहां बताया गया है कि मैं इसे कैसे approach करता हूं।
Step 1: अपने Components Map करें#
अपने component tree से गुजरें और सब कुछ classify करें:
Component State? Effects? Events? → Decision
─────────────────────────────────────────────────────────
Header No No No → Server
NavigationMenu No No Yes → Client (mobile toggle)
Footer No No No → Server
BlogPost No No No → Server
SearchBar Yes Yes Yes → Client
ProductCard No No Yes → Client (onClick) या split
UserAvatar No No No → Server
CommentForm Yes Yes Yes → Client
Sidebar Yes No Yes → Client (collapse toggle)
MarkdownRenderer No No No → Server (बड़ी dependency जीत)
DataTable Yes Yes Yes → Client (sorting, filtering)
Step 2: Data Fetching ऊपर ले जाएं#
सबसे बड़ा architectural change components में useEffect से data fetching को async Server Components में move करना है। यहीं असली migration effort है।
पहले:
// पुराना pattern — Client Component में data fetching
"use client";
function ProductPage({ id }: { id: string }) {
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(data => setProduct(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
if (!product) return <NotFound />;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}बाद:
// नया pattern — Server Component fetch करता है, Client Component interact करता है
// app/products/[id]/page.tsx (Server Component)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await db.product.findUnique({ where: { id } });
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} price={product.price} />
</div>
);
}कोई loading state management नहीं। कोई error state नहीं। कोई useEffect नहीं। Framework Suspense और error boundaries के माध्यम से ये सब handle करता है।
Step 3: Interaction Boundaries पर Components Split करें#
कई components ज्यादातर static होते हैं जिनमें एक छोटा interactive हिस्सा होता है। उन्हें split करें:
पहले (एक बड़ा Client Component):
"use client";
function ProductCard({ product }: { product: Product }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<button onClick={() => setIsFavorite(!isFavorite)}>
{isFavorite ? "♥" : "♡"}
</button>
</div>
);
}बाद (छोटे Client island के साथ Server Component):
// ProductCard.tsx (Server Component)
import { FavoriteButton } from "./FavoriteButton";
function ProductCard({ product }: { product: Product }) {
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<FavoriteButton productId={product.id} />
</div>
);
}// FavoriteButton.tsx (Client Component)
"use client";
import { useState } from "react";
export function FavoriteButton({ productId }: { productId: string }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<button
onClick={() => setIsFavorite(!isFavorite)}
aria-label={isFavorite ? "Favorites से हटाएं" : "Favorites में जोड़ें"}
>
{isFavorite ? "♥" : "♡"}
</button>
);
}Image, title, description, और price अब server-rendered हैं। सिर्फ छोटा favorite button Client Component है। कम JavaScript, तेज page loads।
Step 4: API Routes को Server Actions में बदलें#
अगर आपके पास API routes हैं जो सिर्फ आपके अपने frontend को serve करने के लिए exist करते हैं (external consumers नहीं), तो उनमें से ज्यादातर Server Actions बन सकते हैं:
पहले:
// app/api/contact/route.ts
export async function POST(request: Request) {
const body = await request.json();
// validate, DB में save, email भेजें
return Response.json({ success: true });
}
// Client component
const response = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(formData),
});बाद:
// app/actions/contact.ts
"use server";
export async function submitContactForm(formData: FormData) {
// validate, DB में save, email भेजें
return { success: true };
}
// Client component — बस function directly call करें
import { submitContactForm } from "@/app/actions/contact";API routes इनके लिए रखें: webhooks, external API consumers, कोई भी चीज जिसमें custom HTTP headers या status codes चाहिए, streaming के साथ file uploads।
Step 5: RSC Components की Testing#
Server Components test करने के लिए थोड़ा अलग approach चाहिए क्योंकि ये async हो सकते हैं:
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
// Database mock करें
vi.mock("@/lib/database", () => ({
db: {
product: {
findUnique: vi.fn(),
},
},
}));
describe("ProductPage", () => {
it("product details render करता है", async () => {
const { db } = await import("@/lib/database");
vi.mocked(db.product.findUnique).mockResolvedValue({
id: "1",
name: "Test Product",
description: "A great product",
price: 29.99,
});
// Server Components async हैं — JSX को await करें
const jsx = await ProductPage({
params: Promise.resolve({ id: "1" })
});
render(jsx);
expect(screen.getByText("Test Product")).toBeInTheDocument();
expect(screen.getByText("A great product")).toBeInTheDocument();
});
});मुख्य अंतर: आप component function को await करते हैं क्योंकि ये async है। फिर resulting JSX render करें। बाकी सब कुछ traditional React Testing Library जैसा ही काम करता है।
RSC Projects के लिए File Structure#
यहां वो structure है जिस पर मैं कई projects के बाद आया हूं। ये opinionated है, लेकिन काम करता है:
src/
├── app/
│ ├── layout.tsx ← Root layout (Server Component)
│ ├── page.tsx ← Home page (Server Component)
│ ├── (marketing)/ ← Marketing pages के लिए Route group
│ │ ├── about/page.tsx
│ │ └── pricing/page.tsx
│ ├── (app)/ ← Authenticated app के लिए Route group
│ │ ├── layout.tsx ← Auth check के साथ App shell
│ │ ├── dashboard/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx ← इस route के लिए Suspense fallback
│ │ │ └── error.tsx ← इस route के लिए Error boundary
│ │ └── settings/
│ │ └── page.tsx
│ └── actions/ ← Server Actions
│ ├── auth.ts
│ └── products.ts
├── components/
│ ├── ui/ ← Shared UI primitives (ज्यादातर Client)
│ │ ├── Button.tsx ← "use client"
│ │ ├── Dialog.tsx ← "use client"
│ │ └── Card.tsx ← Server Component (सिर्फ styling)
│ └── features/ ← Feature-specific components
│ ├── products/
│ │ ├── ProductGrid.tsx ← Server (async, data fetch करता है)
│ │ ├── ProductCard.tsx ← Server (presentational)
│ │ ├── ProductSearch.tsx ← Client (useState, input)
│ │ └── AddToCart.tsx ← Client (onClick, mutation)
│ └── blog/
│ ├── PostList.tsx ← Server (async, data fetch करता है)
│ ├── PostContent.tsx ← Server (markdown rendering)
│ └── CommentSection.tsx ← Client (form, real-time)
├── lib/
│ ├── data/ ← Data access layer
│ │ ├── products.ts ← cache() wrapped DB queries
│ │ └── users.ts
│ ├── database.ts
│ └── utils.ts
└── providers/
├── ThemeProvider.tsx ← "use client" — theme चाहिए वाले parts wrap करता है
└── CartProvider.tsx ← "use client" — सिर्फ shop section wrap करता है
मुख्य सिद्धांत:
- Server Components में directive नहीं होता — ये default हैं
- Client Components explicitly marked होते हैं — एक नजर में पता चल जाता है
- Data fetching
lib/data/में रहती है —cache()याunstable_cacheसे wrapped - Server Actions
app/actions/में रहते हैं — app के साथ co-located, स्पष्ट रूप से अलग - Providers minimum आवश्यक को wrap करते हैं — पूरे app को नहीं
निष्कर्ष#
React Server Components सिर्फ एक नई API नहीं हैं। ये सोचने का एक अलग तरीका है कि code कहां चलता है, data कहां रहता है, और pieces कैसे जुड़ते हैं। Mental model shift वास्तविक है और समय लगता है।
लेकिन एक बार जब ये click हो जाता है — एक बार जब आप boundary से लड़ना बंद करके उसके आसपास design करना शुरू करते हैं — तो आप ऐसे apps बनाते हैं जो पहले की तुलना में तेज, सरल, और ज्यादा maintainable हैं। कम JavaScript client को ship होता है। Data fetching को ceremony की जरूरत नहीं। Component tree ही architecture बन जाता है।
Transition इसके लायक है। बस जान लीजिए कि पहले कुछ projects असहज लगेंगे, और ये सामान्य है। आप इसलिए नहीं जूझ रहे कि RSC खराब है। आप इसलिए जूझ रहे हैं क्योंकि ये वाकई नया है।
Server Components से हर जगह शुरू करें। "use client" को leaves तक push करें। धीमी चीजों को Suspense में wrap करें। Data वहां fetch करें जहां render होता है। Children से compose करें।
यही पूरा playbook है। बाकी सब details हैं।