React Server Components: Mentální modely, patterny a nástrahy
Praktický průvodce React Server Components, který jsem si přál mít, když jsem s nimi začínal. Mentální modely, reálné patterny, problém hranice a chyby, které jsem udělal, abys je nemusel opakovat.
Prvních tři měsíce s React Server Components jsem si myslel, že jim rozumím. Četl jsem RFC, sledoval konferenční přednášky, postavil pár demo appek. Sebevědomě jsem se mýlil prakticky ve všem.
Problém není v tom, že RSC jsou složité. Jde o to, že mentální model je opravdu jiný než cokoliv, co jsme v Reactu dělali dříve, a všichni — včetně mě — se to snaží nacpat do staré škatulky. "Je to jako SSR." Není. "Je to jako PHP." Blíž, ale ne. "Jsou to prostě komponenty, co běží na serveru." Technicky pravda, prakticky k ničemu.
Následuje všechno, co jsem skutečně potřeboval vědět, napsané tak, jak bych si přál, aby mi to tehdy někdo vysvětlil. Ne teoretická verze. Ta, kdy v jedenáct v noci zíráš na serializační chybu a potřebuješ pochopit proč.
Mentální model, který opravdu funguje#
Na chvíli zapomeň na všechno, co víš o renderování v Reactu. Tady je nový obrázek.
V tradičním Reactu (client-side) se celý tvůj strom komponent posílá do prohlížeče jako JavaScript. Prohlížeč ho stáhne, zparsuje, spustí a vykreslí výsledek. Každá komponenta — ať už je to 200řádkový interaktivní formulář nebo statický odstavec textu — prochází stejnou pipeline.
React Server Components tohle rozdělují do dvou světů:
Server Components běží na serveru. Spustí se jednou, vyprodukují výstup a pošlou výsledek na klienta — ne kód. Prohlížeč nikdy neuvidí funkci komponenty, nikdy nestáhne její závislosti, nikdy ji znovu nevyrenderuje.
Client Components fungují jako tradiční React. Posílají se do prohlížeče, hydratují se, udržují stav, zpracovávají události. Jsou to ten React, který už znáš.
Klíčový vhled, jehož internalizace mi trvala trapně dlouho: Server Components jsou výchozí. V Next.js App Routeru je každá komponenta Server Component, pokud ji explicitně nepřevedeš na klienta pomocí "use client". Je to opak toho, na co jsme zvyklí, a mění to způsob uvažování o kompozici.
Renderovací vodopád#
Tady je to, co se skutečně děje, když uživatel požádá o stránku:
1. Request hits the server
2. Server executes Server Components top-down
3. When a Server Component hits a "use client" boundary,
it stops — that subtree will render on the client
4. Server Components produce RSC Payload (a special format)
5. RSC Payload streams to the client
6. Client renders Client Components, stitching them into
the server-rendered tree
7. Hydration makes Client Components interactive
Krok 4 je místo, kde žije většina zmatků. RSC Payload není HTML. Je to speciální streamovací formát, který popisuje strom komponent — co server vyrenderoval, kde se musí klient ujmout řízení a jaké props předat přes hranici.
Vypadá přibližně takto (zjednodušeně):
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}]]}]
Nemusíš si tento formát pamatovat. Ale pochopení, že existuje — že mezi serverem a klientem je serializační vrstva — ti ušetří hodiny debugování. Pokaždé, když dostaneš chybu "Props must be serializable", je to proto, že něco, co posíláš, nemůže přežít tento překlad.
Co "běží na serveru" skutečně znamená#
Když řeknu, že Server Component "běží na serveru", myslím to doslova. Funkce komponenty se spustí v Node.js (nebo Edge runtime). To znamená, že můžeš:
// app/dashboard/page.tsx — this is a Server Component by default
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. No API route needed.
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>
);
}Žádný useEffect. Žádná správa loading stavů. Žádný API route, který by věci propojoval. Komponenta je datová vrstva. Tohle je největší výhra RSC a je to věc, která mi zpočátku přišla nejnepohodlnější, protože jsem si pořád říkal "ale kde je ta separace?"
Separace je hranice "use client". Všechno nad ní je server. Všechno pod ní je klient. To je tvá architektura.
Hranice server/klient#
Tady se většině lidí porozumění rozpadá a kde jsem strávil většinu svého debug času v prvních pár měsících.
Direktiva "use client"#
Direktiva "use client" na začátku souboru označí vše exportované z tohoto souboru jako Client Component. Jedná se o anotaci na úrovni modulu, ne na úrovni komponenty.
// src/components/Counter.tsx
"use client";
import { useState } from "react";
// This entire file is now "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>
);
}
// This is ALSO a Client Component because it's in the same file
export function ResetButton({ onReset }: { onReset: () => void }) {
return <button onClick={onReset}>Reset</button>;
}Častá chyba: dát "use client" do barrel souboru (index.ts), který reexportuje všechno. Gratuluju, právě jsi celou svou knihovnu komponent udělal client-side. Viděl jsem, jak týmy takhle náhodou odeslaly 200 KB JavaScriptu.
Co překračuje hranici#
Tady je pravidlo, které tě zachrání: vše, co překračuje hranici server-klient, musí být serializovatelné do JSON.
Co je serializovatelné:
- Stringy, čísla, booleany, null, undefined
- Pole a prosté objekty (obsahující serializovatelné hodnoty)
- Data (serializovaná jako ISO stringy)
- Server Components (jako JSX — k tomu se dostaneme)
- FormData
- Typovaná pole, ArrayBuffer
Co NENÍ serializovatelné:
- Funkce (včetně event handlerů)
- Třídy (instance vlastních tříd)
- Symboly
- DOM uzly
- Streamy (ve většině kontextů)
To znamená, že toto nemůžeš udělat:
// 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 are not serializable
onItemClick={(id) => console.log(id)}
// ERROR: Class instances are not serializable
formatter={new Intl.NumberFormat("en-US")}
/>
);
}Řešení není udělat ze stránky Client Component. Řešení je posunout interaktivitu dolů a načítání dat nahoru:
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
export default async function Page() {
const items = await getItems();
// Only pass serializable data
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);
// Create the formatter on the client side
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>
);
}Mylná představa o "ostrovech"#
Zpočátku jsem přemýšlel o Client Components jako o "ostrovech" — malých interaktivních kouscích v moři server-renderovaného obsahu. To je částečně správně, ale uniká tomu klíčový detail: Client Component může renderovat Server Components, pokud jsou předány jako children nebo props.
To znamená, že hranice není pevná zeď. Je to spíš membrána. Server-renderovaný obsah může proudit skrz Client Components pomocí patternu children. Ponoříme se do toho v sekci o kompozici.
Patterny pro načítání dat#
RSC zásadně mění načítání dat. Žádné další useEffect + useState + loading stavy pro data, která jsou známá v době renderování. Ale nové patterny mají své vlastní záludnosti.
Základní fetch s cachováním#
V Server Component prostě zavoláš fetch. Next.js rozšiřuje globální fetch o přidání cachování:
// app/products/page.tsx
export default async function ProductsPage() {
// Cached by default — same URL returns 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>
);
}Chování cachování můžeš řídit explicitně:
// Revalidate every 60 seconds (ISR-like behavior)
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
// No caching — always fresh data
const res = await fetch("https://api.example.com/user/profile", {
cache: "no-store",
});
// Cache with tags for on-demand revalidation
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});Potom můžeš revalidovat pomocí tagu ze Server Action:
"use server";
import { revalidateTag } from "next/cache";
export async function refreshProducts() {
revalidateTag("products");
}Paralelní načítání dat#
Nejčastější výkonnostní chyba, kterou vidím: sekvenční načítání dat, když by paralelní fungovalo úplně stejně.
Špatně — sekvenční (vodopády):
// app/dashboard/page.tsx — DON'T DO THIS
export default async function Dashboard() {
const user = await getUser(); // 200ms
const orders = await getOrders(); // 300ms
const notifications = await getNotifications(); // 150ms
// Total: 650ms — each waits for the previous one
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}Dobře — paralelní:
// app/dashboard/page.tsx — DO THIS
export default async function Dashboard() {
// All three fire simultaneously
const [user, orders, notifications] = await Promise.all([
getUser(), // 200ms
getOrders(), // 300ms (runs in parallel)
getNotifications(), // 150ms (runs in parallel)
]);
// Total: ~300ms — limited by the slowest request
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}Ještě lepší — paralelní s nezávislými Suspense hranicemi:
// app/dashboard/page.tsx — BEST
import { Suspense } from "react";
export default function Dashboard() {
// Note: this component is NOT async — it delegates to children
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<OrderListSkeleton />}>
<OrderList />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<Notifications />
</Suspense>
</div>
);
}
// Each component fetches its own data
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>;
}Tento poslední pattern je nejsilnější, protože každá sekce se načítá nezávisle. Uživatel vidí obsah, jak se stává dostupným, ne všechno-nebo-nic. Rychlé sekce nečekají na pomalé.
Deduplikace requestů#
Next.js automaticky deduplikuje volání fetch se stejnou URL a parametry během jednoho renderovacího průchodu. To znamená, že nemusíš vytahovat načítání dat nahoru, abys předešel redundantním requestům:
// Both of these components can fetch the same URL
// and Next.js will only make ONE 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, not a second request
const user = await fetch("/api/user").then(r => r.json());
return <aside>Role: {user.role}</aside>;
}Důležitá výhrada: tohle funguje jen s fetch. Pokud používáš ORM nebo databázového klienta přímo, musíš použít Reactovou funkci cache():
import { cache } from "react";
import { db } from "@/lib/database";
// Wrap your data function with cache()
// Now multiple calls in the same render = one actual query
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
});cache() deduplikuje po dobu jednoho serverového requestu. Není to trvalá cache — je to per-request memoizace. Po dokončení requestu jsou cachované hodnoty garbage collectovány.
Patterny kompozice komponent#
Tady se RSC stává opravdu elegantní, jakmile pochopíš patterny. A opravdu matoucí, dokud je nepochopíš.
Pattern "children jako díra"#
Tohle je nejdůležitější kompoziční pattern v RSC a trvalo mi týdny, než jsem ho plně docenil. Problém: máš Client Component, která poskytuje nějaký layout nebo interaktivitu, a chceš uvnitř renderovat Server Components.
Nemůžeš importovat Server Component do souboru Client Component. V momentě, kdy přidáš "use client", je všechno v tom modulu client-side. Ale můžeš předat Server Components jako children:
// 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">
{/* These children can be 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>
{/* These are Server Components, passed through a Client Component */}
<UserProfile />
<NavigationLinks />
</Sidebar>
<main>{children}</main>
</div>
);
}Proč to funguje? Protože Server Components (UserProfile, NavigationLinks) se vyrenderují nejdřív na serveru, a potom se jejich výstup (RSC payload) předá jako children Client Component. Client Component nikdy nemusí vědět, že to byly Server Components — prostě obdrží předrenderované React uzly.
Představ si children jako "díru" v Client Component, kterou může proudit server-renderovaný obsah.
Předávání Server Components jako props#
Pattern s children se zobecňuje na jakoukoli prop, která přijímá React.ReactNode:
// 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 — can fetch data
import { BillingSettings } from "./BillingSettings"; // Server Component — can fetch data
import { SecuritySettings } from "./SecuritySettings"; // Server Component — can fetch data
export default function SettingsPage() {
return (
<TabLayout
tabs={[
{ label: "Profile", content: <ProfileSettings /> },
{ label: "Billing", content: <BillingSettings /> },
{ label: "Security", content: <SecuritySettings /> },
]}
/>
);
}Každá settings komponenta může být asynchronní Server Component, která si načte vlastní data. Client Component (TabLayout) jen zvládá přepínání tabů. Tohle je neuvěřitelně silný pattern.
Async Server Components#
Server Components mohou být async. To je obrovský deal, protože to znamená, že načítání dat probíhá během renderování, ne jako side effect:
// This is valid and beautiful
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 nemohou být async. Pokud se pokusíš udělat "use client" komponentu async, React vyhodí chybu. To je tvrdé omezení.
Suspense hranice: Streaming primitiv#
Suspense je to, jak v RSC získáš streaming. Bez Suspense hranic celá stránka čeká na nejpomalejší async komponentu. S nimi se každá sekce streamuje nezávisle:
// 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 — renders immediately */}
<HeroSection />
{/* Fast data — shows quickly */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
{/* Medium speed — shows when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewCarousel />
</Suspense>
{/* Slow (ML-powered) — shows last, doesn't block the rest */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendationEngine />
</Suspense>
</main>
);
}Uživatel vidí HeroSection okamžitě, potom se streamuje ProductGrid, pak recenze, pak doporučení. Každá Suspense hranice je nezávislý streamovací bod.
Vnořování Suspense hranic je také platné a užitečné:
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</Dashboard>
</Suspense>Pokud je Dashboard rychlý, ale RevenueChart pomalý, vnější Suspense se vyhodnotí jako první (zobrazí shell dashboardu) a vnitřní Suspense pro graf se vyhodnotí později.
Error hranice se Suspense#
Spoj Suspense s error.tsx pro odolná UI:
// app/dashboard/error.tsx — Client Component (must be)
"use client";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-8 text-center">
<h2>Something went wrong loading the 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">
Try again
</button>
</div>
);
}Soubor error.tsx automaticky obalí příslušný segment routy do React Error Boundary. Pokud jakýkoliv Server Component v tom segmentu vyhodí chybu, zobrazí se chybové UI místo zhroucení celé stránky.
Kdy použít co: Rozhodovací strom#
Po vytvoření několika produkčních appek s RSC jsem dospěl k jasnému rozhodovacímu rámci. Tady je skutečný myšlenkový postup, kterým procházím u každé komponenty:
Začni se Server Components (výchozí stav)#
Každá komponenta by měla být Server Component, pokud neexistuje konkrétní důvod, proč nemůže. Tohle je to nejdůležitější pravidlo.
Udělej z ní Client Component, když:#
1. Používá browser-only API
"use client";
// window, document, navigator, localStorage, etc.
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>Loading location...</p>;
}2. Používá React hooky vyžadující stav nebo efekty
useState, useEffect, useReducer, useRef (pro mutable ref), useContext — kterýkoli z nich vyžaduje "use client".
"use client";
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// This component MUST be a Client Component because it
// uses useState and manages user input
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}3. Připojuje event handlery
onClick, onChange, onSubmit, onMouseEnter — jakékoliv interaktivní chování znamená client-side.
4. Používá client-side knihovny
Framer Motion, React Hook Form, Zustand, React Query (pro client-side fetching), jakákoliv knihovna pro grafy, co renderuje na canvas nebo SVG interaktivně.
Nech ji jako Server Component, když:#
- Pouze zobrazuje data (žádná uživatelská interakce)
- Načítá data z databáze nebo API
- Přistupuje k backend zdrojům (souborový systém, env proměnné se secrety)
- Importuje velké závislosti, které klient nepotřebuje (markdown parsery, syntax highlightery, date knihovny pro formátování)
- Renderuje statický nebo polo-statický obsah
Reálné rozhodnutí v praxi#
Tady je konkrétní příklad. Stavím produktovou stránku:
ProductPage (Server)
├── ProductBreadcrumbs (Server) — static navigation, no interactivity
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│ ├── ProductTitle (Server) — just text
│ ├── ProductPrice (Server) — formatted number, no interaction
│ └── AddToCartButton (Client) — onClick, manages cart state
├── ProductDescription (Server) — rendered markdown
├── Suspense
│ └── RelatedProducts (Server) — async data fetch, slow API
└── Suspense
└── ProductReviews (Server)
└── ReviewForm (Client) — form with validation
Všimni si patternu: shell stránky a datově náročné části jsou Server Components. Interaktivní ostrovy (ImageGallery, AddToCartButton, ReviewForm) jsou Client Components. Pomalé sekce (RelatedProducts, ProductReviews) jsou obaleny v Suspense.
Tohle není teoretické. Takto mé stromy komponent skutečně vypadají.
Časté chyby (všechny jsem je udělal)#
Chyba 1: Udělat z všeho Client Component#
Cesta nejmenšího odporu při migraci z Pages Routeru nebo Create React App je plácnout "use client" na všechno. Funguje to! Nic se nerozbije! Taky posíláš celý svůj strom komponent jako JavaScript a nemáš žádné výhody RSC.
Viděl jsem codebase, kde kořenový layout má "use client". V tu chvíli doslova provozuješ client-side React app se zbytečnými kroky navíc.
Řešení: Začni se Server Components. "use client" přidej jen když ti kompilátor řekne, že je to potřeba (protože jsi použil hook nebo event handler). Posouvej "use client" co nejníž ve stromu.
Chyba 2: Prop drilling přes hranici#
// BAD: fetching data in a Server Component, then passing it through
// multiple Client Components
// 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>
);
}Každý kousek dat, který předáš přes hranici, se serializuje do RSC payloadu. Předáš stejný objekt pětkrát? Je v payloadu pětkrát. Viděl jsem RSC payloady nabobtnat na megabajty kvůli tomuhle.
Řešení: Použij kompozici. Místo předávání dat jako props předej Server Components jako children:
// GOOD: Server Components fetch their own data, pass through as children
// app/page.tsx (Server)
export default function Page() {
return (
<ClientShell>
<UserInfo /> {/* Server Component — fetches its own data */}
<Settings /> {/* Server Component — fetches its own data */}
<ClientWidget>
<UserAvatar /> {/* Server Component — fetches its own data */}
</ClientWidget>
</ClientShell>
);
}Chyba 3: Nepoužívat Suspense#
Bez Suspense je Time to First Byte (TTFB) tvé stránky limitován nejpomalejším načtením dat. Měl jsem stránku dashboardu, která se načítala 4 sekundy, protože jeden analytický dotaz byl pomalý, přestože zbytek dat stránky byl připravený za 200 ms.
// BAD: everything waits for everything
export default async function Dashboard() {
const stats = await getStats(); // 200ms
const chart = await getChartData(); // 300ms
const analytics = await getAnalytics(); // 4000ms ← blocks everything
return (
<div>
<Stats data={stats} />
<Chart data={chart} />
<Analytics data={analytics} />
</div>
);
}// GOOD: analytics loads independently
export default function Dashboard() {
return (
<div>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* Takes 4s but doesn't block the rest */}
</Suspense>
</div>
);
}Chyba 4: Serializační chyby za běhu#
Tohle je zvlášť bolestivé, protože to často zachytíš až v produkci. Předáš něco neserializovatelného přes hranici a dostaneš kryptickou chybu:
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.
Časté příčiny:
- Předávání Date objektů (místo toho použij
.toISOString()) - Předávání Map nebo Set (převeď na pole/objekty)
- Předávání instancí tříd z ORM (použij
.toJSON()nebo spread do prostých objektů) - Předávání funkcí (přesuň logiku do Client Component nebo použij Server Actions)
- Předávání výsledků Prisma modelů s
Decimalpoli (převeď nanumbernebostring)
// BAD
const user = await prisma.user.findUnique({ where: { id } });
// user might have non-serializable fields (Decimal, BigInt, etc.)
return <ClientProfile user={user} />;
// GOOD
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} />;Chyba 5: Používat Context na všechno#
useContext funguje jen v Client Components. Pokud se pokusíš použít React context v Server Component, nebude to fungovat. Viděl jsem lidi, jak z celé appky udělali Client Component jen kvůli theme contextu.
Řešení: Pro témata a jiný globální stav použij CSS proměnné nastavené na straně serveru, nebo použij funkce cookies() / headers():
// 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>
);
}Pro skutečně client-side stav (auth tokeny, nákupní košíky, real-time data) vytvoř tenký Client Component provider na příslušné úrovni — ne na 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 must be used within CartProvider");
return context;
}// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
// CartProvider is a Client Component, but children flow through as server content
return <CartProvider>{children}</CartProvider>;
}Chyba 6: Ignorovat dopad na velikost bundlu#
Jednou z největších výher RSC je, že kód Server Component se nikdy neposílá na klienta. Ale musíš o tom aktivně přemýšlet. Pokud máš komponentu, co používá 50KB markdown parser a jen zobrazuje vyrenderovaný obsah — měla by být Server Component. Parser zůstane na serveru a na klienta jde jen HTML výstup.
// Server Component — marked stays on the server
import { marked } from "marked"; // 50KB library — never ships to client
async function BlogContent({ slug }: { slug: string }) {
const post = await getPost(slug);
const html = marked(post.markdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}Pokud bys z tohohle udělal Client Component, marked by se poslal do prohlížeče. Zbytečně. Uživatel by stáhl 50 KB JavaScriptu jen proto, aby vyrenderoval obsah, který mohl být HTML od začátku.
Zkontroluj svůj bundle pomocí @next/bundle-analyzer. Výsledky tě možná překvapí.
Strategie cachování#
Cachování v Next.js 15+ bylo oproti dřívějším verzím výrazně zjednodušeno, ale stále existují různé vrstvy, kterým je třeba porozumět.
Funkce cache() (React)#
Reactová cache() je pro per-request deduplikaci, ne pro trvalé cachování:
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 } });
});
// Call this anywhere in your component tree during a single request.
// Only one actual database query will execute.Toto má rozsah jednoho serverového requestu. Jakmile je request hotový, cachovaná hodnota zmizí. Je to memoizace, ne cachování.
unstable_cache (Next.js)#
Pro trvalé cachování napříč requesty použij unstable_cache (název je "unstable" odjakživa, ale v produkci funguje bez problémů):
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 every hour
tags: ["products"], // for on-demand revalidation
}
);
// Usage in a Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
const products = await getCachedProducts(categoryId);
return <Grid items={products} />;
}Pro invalidaci:
"use server";
import { revalidateTag } from "next/cache";
export async function onProductUpdate() {
revalidateTag("products");
}Statické vs dynamické renderování#
Next.js rozhoduje, zda je routa statická nebo dynamická, na základě toho, co v ní používáš:
Statické (renderované při buildu, cachované):
- Žádné dynamické funkce (
cookies(),headers(),searchParams) - Všechna volání
fetchmají cachování povolené - Žádné
export const dynamic = "force-dynamic"
Dynamické (renderované na každý request):
- Používá
cookies(),headers()nebosearchParams - Používá
fetchscache: "no-store" - Má
export const dynamic = "force-dynamic" - Používá
connection()neboafter()znext/server
Které routy jsou statické vs dynamické můžeš zkontrolovat spuštěním next build — ukazuje legendu dole:
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
Hierarchie cachování#
Přemýšlej o cachování ve vrstvách:
1. React cache() — per-request, in-memory, automatic dedup
2. fetch() cache — cross-request, automatic for GET requests
3. unstable_cache() — cross-request, for non-fetch operations
4. Full Route Cache — rendered HTML cached at build/revalidation time
5. Router Cache (client) — in-browser cache of visited routes
Každá vrstva slouží jinému účelu. Ne vždy potřebuješ všechny, ale pochopení, která je aktivní, pomáhá debugovat problémy typu "proč se mi data neaktualizují?"
Reálná strategie cachování#
Tady je to, co skutečně dělám v produkci:
// lib/data/products.ts
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
// Per-request dedup: call this multiple times in one render,
// only one DB query runs
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { category: true, images: true },
});
});
// Cross-request cache: results persist across requests,
// revalidate every 5 minutes or on-demand via tag
export const getPopularProducts = unstable_cache(
async () => {
return db.product.findMany({
orderBy: { salesCount: "desc" },
take: 20,
include: { images: true },
});
},
["popular-products"],
{ revalidate: 300, tags: ["products"] }
);
// No caching: always fresh (for user-specific data)
export const getUserCart = cache(async (userId: string) => {
// cache() here is only for per-request dedup, not persistence
return db.cart.findUnique({
where: { userId },
include: { items: { include: { product: true } } },
});
});Pravidlo: veřejná data, která se mění zřídka, dostanou unstable_cache. User-specific data dostanou cache() jen pro deduplikaci. Real-time data nedostanou žádné cachování.
Server Actions: Most zpátky#
Server Actions si zaslouží vlastní sekci, protože uzavírají příběh RSC. Jsou to způsob, jakým Client Components komunikují zpět se serverem bez API rout.
// 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: "Invalid email address" };
}
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: "Already subscribed" };
}
return { error: "Something went wrong" };
}
}// 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 ? "Subscribing..." : "Subscribe"}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">Subscribed!</p>}
</form>
);
}Server Actions jsou odpovědí na otázku "jak mutuju data?" ve světě RSC. Nahrazují většinu API rout pro odesílání formulářů, mutace a side effecty.
Klíčová pravidla pro Server Actions:
- Vždy validuj vstup (funkce je volatelná z klienta — zacházej s ní jako s API endpointem)
- Vždy vracej serializovatelná data
- Běží na serveru, takže můžeš přistupovat k databázím, souborovým systémům, secretům
- Mohou volat
revalidatePath()neborevalidateTag()pro aktualizaci cachovaných dat po mutacích
Patterny migrace#
Pokud máš existující React app (Pages Router, Create React App, Vite), přechod na RSC nemusí být přepisování. Tady je můj přístup.
Krok 1: Zmapuj své komponenty#
Projdi strom komponent a klasifikuj všechno:
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) or split
UserAvatar No No No → Server
CommentForm Yes Yes Yes → Client
Sidebar Yes No Yes → Client (collapse toggle)
MarkdownRenderer No No No → Server (big dependency win)
DataTable Yes Yes Yes → Client (sorting, filtering)
Krok 2: Přesuň načítání dat nahoru#
Největší architektonická změna je přesunutí načítání dat z useEffect v komponentách na async Server Components. Tady žije skutečný migrační effort.
Před:
// Old pattern — data fetching in a Client Component
"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>
);
}Po:
// New pattern — Server Component fetches, Client Component interacts
// 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>
);
}Žádná správa loading stavu. Žádný error state. Žádný useEffect. Framework to všechno zvládá přes Suspense a error boundary.
Krok 3: Rozděl komponenty na hranicích interaktivity#
Mnoho komponent je většinou statických s malou interaktivní částí. Rozděl je:
Před (jedna velká 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>
);
}Po (Server Component s malým Client ostrovem):
// 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 ? "Remove from favorites" : "Add to favorites"}
>
{isFavorite ? "♥" : "♡"}
</button>
);
}Obrázek, název, popis a cena se teď renderují na serveru. Jen malé tlačítko oblíbených je Client Component. Méně JavaScriptu, rychlejší načtení stránky.
Krok 4: Převeď API routy na Server Actions#
Pokud máš API routy, které existují jen proto, aby obsluhovaly tvůj vlastní frontend (ne externí spotřebitele), většinu z nich můžeš převést na Server Actions:
Před:
// app/api/contact/route.ts
export async function POST(request: Request) {
const body = await request.json();
// validate, save to DB, send email
return Response.json({ success: true });
}
// Client component
const response = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(formData),
});Po:
// app/actions/contact.ts
"use server";
export async function submitContactForm(formData: FormData) {
// validate, save to DB, send email
return { success: true };
}
// Client component — just call the function directly
import { submitContactForm } from "@/app/actions/contact";API routy si ponech pro: webhooky, externí API konzumenty, cokoliv, co potřebuje vlastní HTTP headery nebo status kódy, nahrávání souborů se streamingem.
Krok 5: Testování RSC komponent#
Testování Server Components vyžaduje mírně odlišný přístup, protože mohou být async:
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
// Mock the database
vi.mock("@/lib/database", () => ({
db: {
product: {
findUnique: vi.fn(),
},
},
}));
describe("ProductPage", () => {
it("renders product details", 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 are async — await the JSX
const jsx = await ProductPage({
params: Promise.resolve({ id: "1" })
});
render(jsx);
expect(screen.getByText("Test Product")).toBeInTheDocument();
expect(screen.getByText("A great product")).toBeInTheDocument();
});
});Klíčový rozdíl: zavoláš await na funkci komponenty, protože je async. Pak vyrenderuješ výsledné JSX. Všechno ostatní funguje stejně jako s tradiční React Testing Library.
Struktura souborů pro RSC projekty#
Tady je struktura, ke které jsem konvergoval po několika projektech. Je to opinionated, ale funguje:
src/
├── app/
│ ├── layout.tsx ← Root layout (Server Component)
│ ├── page.tsx ← Home page (Server Component)
│ ├── (marketing)/ ← Route group for marketing pages
│ │ ├── about/page.tsx
│ │ └── pricing/page.tsx
│ ├── (app)/ ← Route group for authenticated app
│ │ ├── layout.tsx ← App shell with auth check
│ │ ├── dashboard/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx ← Suspense fallback for this route
│ │ │ └── error.tsx ← Error boundary for this route
│ │ └── settings/
│ │ └── page.tsx
│ └── actions/ ← Server Actions
│ ├── auth.ts
│ └── products.ts
├── components/
│ ├── ui/ ← Shared UI primitives (mostly Client)
│ │ ├── Button.tsx ← "use client"
│ │ ├── Dialog.tsx ← "use client"
│ │ └── Card.tsx ← Server Component (just styling)
│ └── features/ ← Feature-specific components
│ ├── products/
│ │ ├── ProductGrid.tsx ← Server (async, fetches data)
│ │ ├── ProductCard.tsx ← Server (presentational)
│ │ ├── ProductSearch.tsx ← Client (useState, input)
│ │ └── AddToCart.tsx ← Client (onClick, mutation)
│ └── blog/
│ ├── PostList.tsx ← Server (async, fetches data)
│ ├── 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" — wraps parts that need theme
└── CartProvider.tsx ← "use client" — wraps shop section only
Klíčové principy:
- Server Components nemají direktivu — jsou výchozí
- Client Components jsou explicitně označené — na první pohled poznáš
- Načítání dat žije v
lib/data/— obalenécache()nebounstable_cache - Server Actions žijí v
app/actions/— ko-lokované s appkou, jasně oddělené - Providery obalují minimum nutného — ne celou app
Závěr#
React Server Components nejsou jen nové API. Jsou to odlišný způsob uvažování o tom, kde kód běží, kde data žijí a jak se části propojují. Posun v mentálním modelu je skutečný a vyžaduje čas.
Ale jakmile to zaklapne — jakmile přestaneš bojovat s hranicí a začneš navrhovat kolem ní — skončíš s aplikacemi, které jsou rychlejší, jednodušší a lépe udržovatelné než to, co jsme stavěli předtím. Na klienta se posílá méně JavaScriptu. Načítání dat nevyžaduje ceremonie. Strom komponent se stává architekturou.
Přechod stojí za to. Jen věz, že první pár projektů bude nepohodlných, a to je normální. Netrápíš se, protože RSC jsou špatné. Trápíš se, protože jsou opravdu nové.
Začni se Server Components všude. Posouvej "use client" dolů k listům. Pomalé věci obal do Suspense. Data načítej tam, kde se renderují. Skládej přes children.
To je celá příručka. Všechno ostatní jsou detaily.