React Server Components: Mentala modeller, mönster och fallgropar
Den praktiska guiden till React Server Components jag önskade hade funnits när jag började. Mentala modeller, verkliga mönster, gränsproblemen och misstag jag gjorde så att du slipper.
Jag tillbringade de tre första månaderna med React Server Components i tron att jag förstod dem. Jag hade läst RFC:erna, sett konferensföredragen och byggt ett par demo-appar. Jag hade självsäkert fel om nästan allt.
Problemet är inte att RSC är komplicerat. Problemet är att den mentala modellen är genuint annorlunda än allt vi gjort i React tidigare, och alla — inklusive jag — försöker pressa in det i den gamla formen. "Det är som SSR." Nej. "Det är som PHP." Närmare, men nej. "Det är bara komponenter som körs på servern." Tekniskt korrekt, praktiskt värdelöst.
Det som följer är allt jag faktiskt behövde veta, skrivet på det sätt jag önskar att någon hade förklarat det för mig. Inte den teoretiska versionen. Den där du sitter och stirrar på ett serialiseringsfel klockan 23 och behöver förstå varför.
Den mentala modellen som faktiskt fungerar#
Glöm allt du vet om React-rendering för en stund. Här är den nya bilden.
I traditionell React (klientsidan) skickas hela ditt komponentträd till webbläsaren som JavaScript. Webbläsaren laddar ner det, parsar det, kör det och renderar resultatet. Varje komponent — oavsett om det är ett 200 rader långt interaktivt formulär eller ett statiskt textstycke — går genom samma pipeline.
React Server Components delar upp detta i två världar:
Server Components körs på servern. De körs en gång, producerar sin output och skickar resultatet till klienten — inte koden. Webbläsaren ser aldrig komponentfunktionen, laddar aldrig ner dess beroenden och renderar den aldrig om.
Client Components fungerar som traditionell React. De skickas till webbläsaren, hydreras, behåller state, hanterar events. De är den React du redan känner till.
Den viktigaste insikten som tog mig pinsamt lång tid att internalisera: Server Components är standard. I Next.js App Router är varje komponent en Server Component om du inte uttryckligen väljer klientsidan med "use client". Det här är motsatsen till vad vi är vana vid, och det förändrar hur du tänker kring komposition.
Rendering-vattenfallet#
Här är vad som faktiskt händer när en användare begär en sida:
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
Steg 4 är där den mesta förvirringen lever. RSC Payload är inte HTML. Det är ett speciellt streamingformat som beskriver komponentträdet — vad servern renderade, var klienten behöver ta över och vilka props som ska passas över gränsen.
Det ser ungefär ut så här (förenklat):
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}]]}]
Du behöver inte memorera det här formatet. Men att förstå att det existerar — att det finns ett serialiseringslager mellan server och klient — kommer att spara dig timmar av felsökning. Varje gång du får ett "Props must be serializable"-fel beror det på att något du skickar inte kan överleva den här översättningen.
Vad "körs på servern" verkligen innebär#
När jag säger att en Server Component "körs på servern" menar jag det bokstavligt. Komponentfunktionen körs i Node.js (eller Edge-runtime). Det innebär att du kan:
// 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>
);
}Inget useEffect. Ingen laddningsstate-hantering. Ingen API-route för att limma ihop saker. Komponenten är datalagret. Det här är den största vinsten med RSC och det var det som kändes mest obekvämt i början, för jag tänkte hela tiden "men var är separationen?"
Separationen är "use client"-gränsen. Allt ovanför den är server. Allt nedanför den är klient. Det är din arkitektur.
Server/klient-gränsen#
Det här är där de flesta personers förståelse bryter samman, och där jag lade mest felsökningstid de första månaderna.
"use client"-direktivet#
"use client"-direktivet högst upp i en fil markerar allt som exporteras från den filen som en Client Component. Det är en annotation på modulnivå, inte på komponentnivå.
// 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>;
}Vanligt misstag: att lägga "use client" i en barrel-fil (index.ts) som re-exporterar allt. Grattis, du har precis gjort hela ditt komponentbibliotek klientsides. Jag har sett team av misstag skicka 200KB JavaScript på det sättet.
Vad som passerar gränsen#
Här är regeln som kommer att rädda dig: allt som passerar server-klient-gränsen måste vara serialiserbart till JSON.
Vad som är serialiserbart:
- Strängar, nummer, booleans, null, undefined
- Arrays och vanliga objekt (som innehåller serialiserbara värden)
- Datum (serialiserade som ISO-strängar)
- Server Components (som JSX — vi kommer till detta)
- FormData
- Typade arrays, ArrayBuffer
Vad som INTE är serialiserbart:
- Funktioner (inklusive event handlers)
- Klasser (instanser av anpassade klasser)
- Symboler
- DOM-noder
- Streams (i de flesta sammanhang)
Det innebär att du inte kan göra så här:
// 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")}
/>
);
}Lösningen är inte att göra sidan till en Client Component. Lösningen är att flytta interaktivitet nedåt och datahämtning uppåt:
// 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 }) {
// 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>
);
}Missuppfattningen om "öar"#
Jag trodde först att Client Components var "öar" — små interaktiva bitar i ett hav av server-renderat innehåll. Det stämmer delvis men missar en avgörande detalj: en Client Component kan rendera Server Components om de skickas som children eller props.
Det innebär att gränsen inte är en hård vägg. Det är mer som en membran. Server-renderat innehåll kan flöda genom Client Components via children-mönstret. Vi dyker in i detta i kompositionsavsnittet.
Mönster för datahämtning#
RSC förändrar datahämtningen i grunden. Inga fler useEffect + useState + laddningstillstånd för data som är känd vid renderingstid. Men de nya mönstren har sina egna fallgropar.
Enkel fetch med cachning#
I en Server Component gör du bara fetch. Next.js utökar den globala fetch för att lägga till cachning:
// 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>
);
}Styr cachningsbeteendet explicit:
// 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"] },
});Sedan kan du revalidera via tagg från en Server Action:
"use server";
import { revalidateTag } from "next/cache";
export async function refreshProducts() {
revalidateTag("products");
}Parallell datahämtning#
Det vanligaste prestandamisstaget jag ser: sekventiell datahämtning när parallell hade fungerat lika bra.
Dåligt — sekventiellt (vattenfall):
// 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>
);
}Bra — parallellt:
// 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>
);
}Ännu bättre — parallellt med oberoende Suspense-gränser:
// 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>;
}Det sista mönstret är det kraftfullaste eftersom varje sektion laddas oberoende. Användaren ser innehåll allt eftersom det blir tillgängligt, inte allt-eller-inget. De snabba sektionerna väntar inte på de långsamma.
Deduplicering av förfrågningar#
Next.js deduplicerar automatiskt fetch-anrop med samma URL och alternativ under en enda renderingsomgång. Det innebär att du inte behöver lyfta upp datahämtning för att undvika redundanta förfrågningar:
// 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>;
}Viktig varning: detta fungerar bara med fetch. Om du använder en ORM eller databasklient direkt behöver du använda Reacts cache()-funktion:
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() deduplicerar under livstiden för en enda serverförfrågan. Det är inte en bestående cache — det är en per-request-memoization. När förfrågan är klar samlas de cachade värdena in av garbage collector.
Kompositionsmönster för komponenter#
Det här är där RSC blir genuint elegant, när du väl förstår mönstren. Och genuint förvirrande, tills du gör det.
"Children som ett hål"-mönstret#
Det här är det viktigaste kompositionsmönstret i RSC och det tog mig veckor att fullt ut uppskatta det. Här är problemet: du har en Client Component som tillhandahåller layout eller interaktivitet, och du vill rendera Server Components inuti den.
Du kan inte importera en Server Component i en Client Component-fil. I det ögonblick du lägger till "use client" är allt i den modulen klientsides. Men du kan skicka Server Components som 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>
);
}Varför fungerar det här? Eftersom Server Components (UserProfile, NavigationLinks) renderas på servern först, sedan skickas deras output (RSC payload) som children till Client Component. Client Component behöver aldrig veta att de var Server Components — den får bara förrenderade React-noder.
Tänk på children som ett "hål" i Client Component där server-renderat innehåll kan flöda igenom.
Skicka Server Components som props#
Children-mönstret generaliseras till vilken prop som helst som accepterar 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 /> },
]}
/>
);
}Varje inställningskomponent kan vara en asynkron Server Component som hämtar sin egen data. Client Component (TabLayout) hanterar bara flikväxlingen. Det här är ett otroligt kraftfullt mönster.
Asynkrona Server Components#
Server Components kan vara async. Det här är en stor grej eftersom det innebär att datahämtning sker under rendering, inte som en sidoeffekt:
// 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 kan inte vara async. Om du försöker göra en "use client"-komponent async kommer React att kasta ett fel. Det här är en hård begränsning.
Suspense-gränser: Streaming-primitiven#
Suspense är hur du får streaming i RSC. Utan Suspense-gränser väntar hela sidan på den långsammaste asynkrona komponenten. Med dem streamar varje sektion oberoende:
// 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>
);
}Användaren ser HeroSection direkt, sedan streamar ProductGrid in, sedan recensioner, sedan rekommendationer. Varje Suspense-gräns är en oberoende streamingpunkt.
Att nästla Suspense-gränser är också giltigt och användbart:
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</Dashboard>
</Suspense>Om Dashboard är snabb men RevenueChart är långsam, löses den yttre Suspense först (visar dashboard-skalet), och den inre Suspense för diagrammet löses senare.
Error Boundaries med Suspense#
Para ihop Suspense med error.tsx för motståndskraftiga gränssnitt:
// 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>
);
}Filen error.tsx omsluter automatiskt det motsvarande routesegmentet i en React Error Boundary. Om någon Server Component i det segmentet kastar ett undantag visas felgränssnittet istället för att hela sidan kraschar.
När du ska använda vad: Beslutsträdet#
Efter att ha byggt flera produktionsappar med RSC har jag landat i ett tydligt beslutsramverk. Här är den faktiska tankeprocess jag går igenom för varje komponent:
Börja med Server Components (standarden)#
Varje komponent bör vara en Server Component om det inte finns en specifik anledning att den inte kan vara det. Det här är den enskilt viktigaste regeln.
Gör den till en Client Component när:#
1. Den använder API:er som bara finns i webbläsaren
"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. Den använder React hooks som kräver state eller effects
useState, useEffect, useReducer, useRef (för muterbara refs), useContext — vilken som helst av dessa kräver "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. Den kopplar event handlers
onClick, onChange, onSubmit, onMouseEnter — all interaktivt beteende innebär klientsidan.
4. Den använder klientsides-bibliotek
Framer Motion, React Hook Form, Zustand, React Query (för klientsides datahämtning), alla diagrambibliotek som renderar till canvas eller interaktiv SVG.
Behåll den som Server Component när:#
- Den bara visar data (ingen användarinteraktion)
- Den hämtar data från en databas eller API
- Den nyttjar backend-resurser (filsystem, miljövariabler med hemligheter)
- Den importerar stora beroenden som klienten inte behöver (markdown-parsers, syntaxmarkerare, datumbibliotek för formatering)
- Den renderar statiskt eller semi-statiskt innehåll
Det verkliga beslutet i praktiken#
Här är ett konkret exempel. Jag bygger en produktsida:
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
Lägg märke till mönstret: sidskalet och de dataintensiva delarna är Server Components. Interaktiva öar (ImageGallery, AddToCartButton, ReviewForm) är Client Components. Långsamma sektioner (RelatedProducts, ProductReviews) är omslutna av Suspense.
Det här är inte teori. Det är så mina komponentträd faktiskt ser ut.
Vanliga misstag (jag har gjort alla)#
Misstag 1: Göra allting till Client Components#
Den enklaste vägen vid migrering från Pages Router eller Create React App är att klistra "use client" på allt. Det fungerar! Inget går sönder! Du skickar också hela ditt komponentträd som JavaScript och får noll RSC-fördelar.
Jag har sett kodbaser där root layout har "use client". Vid den punkten kör du bokstavligen en klientsides React-app med extra steg.
Lösningen: Börja med Server Components. Lägg bara till "use client" när kompilatorn säger att det behövs (för att du använt en hook eller event handler). Tryck ner "use client" så långt ner i trädet som möjligt.
Misstag 2: Prop drilling genom gränsen#
// 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>
);
}Varje bit data du skickar genom gränsen serialiseras in i RSC payload. Skickar du samma objekt fem gånger? Det finns i payloaden fem gånger. Jag har sett RSC payloads svälla till megabyte på grund av detta.
Lösningen: Använd komposition. Skicka Server Components som children istället för att skicka data som props:
// 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>
);
}Misstag 3: Inte använda Suspense#
Utan Suspense begränsas din sidas Time to First Byte (TTFB) av din långsammaste datahämtning. Jag hade en dashboard-sida som tog 4 sekunder att ladda för att en analysfråga var långsam, trots att resten av sidans data var klar på 200ms.
// 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>
);
}Misstag 4: Serialiseringsfel vid runtime#
Det här är särskilt smärtsamt eftersom du ofta inte upptäcker det förrän i produktion. Du skickar något icke-serialiserbart genom gränsen och får ett kryptiskt fel:
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.
Vanliga bovar:
- Skicka Date-objekt (använd
.toISOString()istället) - Skicka Map eller Set (konvertera till arrays/objekt)
- Skicka klassinstanser från ORM:er (använd
.toJSON()eller sprid ut till vanliga objekt) - Skicka funktioner (flytta logiken till Client Component eller använd Server Actions)
- Skicka Prisma-modellresultat med
Decimal-fält (konvertera tillnumberellerstring)
// 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} />;Misstag 5: Använda Context för allt#
useContext fungerar bara i Client Components. Om du försöker använda en React-context i en Server Component kommer det inte att fungera. Jag har sett folk göra hela sin app till en Client Component bara för att använda en theme-context.
Lösningen: För teman och annan global state, använd CSS-variabler som sätts på serversidan, eller använd funktionerna 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>
);
}För genuint klientsides state (auth-tokens, varukorgar, realtidsdata), skapa en tunn Client Component-provider på rätt nivå — inte i roten:
// 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>;
}Misstag 6: Ignorera påverkan på bundlestorleken#
En av de största vinsterna med RSC är att Server Component-kod aldrig skickas till klienten. Men du behöver tänka på det aktivt. Om du har en komponent som använder en 50KB markdown-parser och bara visar renderat innehåll — den bör vara en Server Component. Parsern stannar på servern, och bara HTML-outputen går till klienten.
// 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 }} />;
}Om du gjorde denna till en Client Component skulle marked skickas till webbläsaren. För ingenting. Användaren skulle ladda ner 50KB JavaScript bara för att rendera innehåll som kunde ha varit HTML från början.
Kontrollera din bundle med @next/bundle-analyzer. Resultaten kan överraska dig.
Cachningsstrategi#
Cachning i Next.js 15+ har förenklats avsevärt jämfört med tidigare versioner, men det finns fortfarande distinkta lager att förstå.
cache()-funktionen (React)#
Reacts cache() är för per-request-deduplicering, inte bestående cachning:
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.Det här begränsas till en enda serverförfrågan. När förfrågan är klar försvinner det cachade värdet. Det är memoization, inte cachning.
unstable_cache (Next.js)#
För bestående cachning över förfrågningar, använd unstable_cache (namnet har varit "unstable" i evigheter, men det fungerar utmärkt i produktion):
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} />;
}För att invalidera:
"use server";
import { revalidateTag } from "next/cache";
export async function onProductUpdate() {
revalidateTag("products");
}Statisk vs dynamisk rendering#
Next.js bestämmer om en route är statisk eller dynamisk baserat på vad du använder i den:
Statisk (renderas vid byggtid, cachelagras):
- Inga dynamiska funktioner (
cookies(),headers(),searchParams) - Alla
fetch-anrop har cachning aktiverat - Inget
export const dynamic = "force-dynamic"
Dynamisk (renderas per förfrågan):
- Använder
cookies(),headers()ellersearchParams - Använder
fetchmedcache: "no-store" - Har
export const dynamic = "force-dynamic" - Använder
connection()ellerafter()frånnext/server
Du kan kontrollera vilka routes som är statiska vs dynamiska genom att köra next build — den visar en förklaring längst ner:
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
Cachningshierarkin#
Tänk på cachning i lager:
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
Varje lager tjänar ett annat syfte. Du behöver inte alltid alla, men att förstå vilket som är aktivt hjälper dig felsöka "varför uppdateras inte min data?"-problem.
En verklig cachningsstrategi#
Här är vad jag faktiskt gör i produktion:
// 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 } } },
});
});Tumregeln: publik data som ändras sällan får unstable_cache. Användarspecifik data får cache() enbart för deduplicering. Realtidsdata får ingen cachning alls.
Server Actions: Vägen tillbaka#
Server Actions förtjänar en egen sektion eftersom de fullbordar RSC-berättelsen. De är hur Client Components kommunicerar tillbaka till servern utan API-routes.
// 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 är svaret på "hur muterar jag data?" i RSC-världen. De ersätter de flesta API-routes för formulärinlämningar, mutationer och sidoeffekter.
Viktiga regler för Server Actions:
- Validera alltid input (funktionen är anropbar från klienten — behandla den som en API-endpoint)
- Returnera alltid serialiserbar data
- De körs på servern, så du kan nå databaser, filsystem, hemligheter
- De kan anropa
revalidatePath()ellerrevalidateTag()för att uppdatera cachad data efter mutationer
Migreringsmönster#
Om du har en befintlig React-app (Pages Router, Create React App, Vite) behöver flytten till RSC inte vara en omskrivning. Här är hur jag angriper det.
Steg 1: Kartlägg dina komponenter#
Gå igenom ditt komponentträd och klassificera allt:
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)
Steg 2: Flytta datahämtningen uppåt#
Den största arkitekturella förändringen är att flytta datahämtning från useEffect i komponenter till async Server Components. Det här är där det verkliga migrationsarbetet ligger.
Före:
// 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>
);
}Efter:
// 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>
);
}Ingen laddningsstate-hantering. Inget feltillstånd. Inget useEffect. Ramverket hanterar allt det genom Suspense och error boundaries.
Steg 3: Dela komponenter vid interaktionsgränser#
Många komponenter är mestadels statiska med en liten interaktiv del. Dela upp dem:
Före (en stor 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>
);
}Efter (Server Component med en liten Client-ö):
// 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>
);
}Bilden, titeln, beskrivningen och priset är nu server-renderade. Bara den lilla favoritknappen är en Client Component. Mindre JavaScript, snabbare sidladdningar.
Steg 4: Konvertera API-routes till Server Actions#
Om du har API-routes som enbart existerar för att serva ditt eget frontend (inte externa konsumenter) kan de flesta av dem bli Server Actions:
Före:
// 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),
});Efter:
// 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";Behåll API-routes för: webhooks, externa API-konsumenter, allt som behöver anpassade HTTP-headers eller statuskoder, filuppladdningar med streaming.
Steg 5: Testa RSC-komponenter#
Att testa Server Components kräver ett något annorlunda angreppssätt eftersom de kan vara asynkrona:
// __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();
});
});Den avgörande skillnaden: du gör await på komponentfunktionen eftersom den är asynkron. Sedan renderar du den resulterande JSX:en. Allt annat fungerar likadant som med traditionellt React Testing Library.
Filstruktur för RSC-projekt#
Här är strukturen jag har landat i efter flera projekt. Den är egensinnig, men den fungerar:
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
De viktigaste principerna:
- Server Components har inget direktiv — de är standarden
- Client Components är uttryckligen markerade — du kan se det direkt
- Datahämtning lever i
lib/data/— omsluten medcache()ellerunstable_cache - Server Actions lever i
app/actions/— samlokaliserade med appen, tydligt separerade - Providers omsluter bara det nödvändigaste — inte hela appen
Slutsatsen#
React Server Components är inte bara ett nytt API. De är ett annat sätt att tänka om var kod körs, var data lever och hur delarna hänger ihop. Det mentala modellskiftet är verkligt och det tar tid.
Men när det klickar — när du slutar kämpa mot gränsen och börjar designa kring den — får du appar som är snabbare, enklare och mer underhållbara än vad vi byggde förut. Mindre JavaScript skickas till klienten. Datahämtning kräver ingen ceremoni. Komponentträdet blir arkitekturen.
Övergången är värd det. Var bara beredd på att de första projekten kommer att kännas obekväma, och det är normalt. Du kämpar inte för att RSC är dåligt. Du kämpar för att det är genuint nytt.
Börja med Server Components överallt. Tryck ner "use client" till löven. Omslut långsamma saker med Suspense. Hämta data där det renderas. Komponera genom children.
Det är hela spelplanen. Allt annat är detaljer.