React Server Components (RSC) to największa zmiana w React od wprowadzenia hooks. Zmieniają fundamentalnie sposób myślenia o tym gdzie i jak renderować komponenty. Na rozmowach rekrutacyjnych w 2025/2026 znajomość RSC jest obowiązkowa dla React developerów - pytania padają o różnice z Client Components, Server Actions, streaming, i praktyczne patterns.
Odpowiedź w 30 sekund
Czym są React Server Components?
Server Components to komponenty które renderują się wyłącznie na serwerze - nigdy nie trafiają do bundle JavaScript klienta. Mogą bezpośrednio czytać z bazy danych, systemu plików, i API bez tworzenia dodatkowych endpointów. Nie mają dostępu do hooks (useState, useEffect) ani browser APIs. Domyślnie w Next.js App Router wszystkie komponenty są Server Components - dodajesz "use client" gdy potrzebujesz interaktywności.
Odpowiedź w 2 minuty
React Server Components rozwiązują fundamentalny problem tradycyjnych React aplikacji - cały kod komponentów trafia do klienta, nawet jeśli służy tylko do wyświetlenia statycznych danych. W klasycznej architekturze React komponent musi: pobrać dane z API, obsłużyć loading/error states, i renderować UI. Cały ten kod trafia do bundle.
RSC przenoszą rendering na serwer. Server Component wykonuje się na serwerze, ma bezpośredni dostęp do bazy danych i systemu plików, i wysyła do klienta gotowy wynik - HTML plus specjalny format RSC Payload który React używa do aktualizacji DOM. Kod samego komponentu nigdy nie trafia do klienta.
Kluczowa koncepcja to podział na Server i Client Components. Server Components są domyślne - nie mają dostępu do useState, useEffect, event handlers, ani browser APIs. Client Components oznaczasz dyrektywą "use client" na początku pliku - mają pełną interaktywność ale są w bundle klienta.
Ważne: "use client" nie oznacza że komponent renderuje się tylko na kliencie. Client Components nadal są SSR (Server-Side Rendered) dla pierwszego ładowania strony, ale potem hydrowane na kliencie. Różnica polega na tym że Server Components nie są hydrowane - nie ma ich w bundle JavaScript.
Server Actions to funkcje async oznaczone "use server" które wykonują się na serwerze ale mogą być wywoływane z klienta. Idealne do form submissions i mutacji danych - integrują się z formularzami przez prop action.
// Server Component (domyślny)
async function UserProfile({ userId }: { userId: string }) {
// Bezpośredni dostęp do bazy - wykonuje się na serwerze
const user = await db.users.findUnique({ where: { id: userId } });
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
{/* Client Component dla interaktywności */}
<FollowButton userId={userId} />
</div>
);
}
// Client Component
"use client";
function FollowButton({ userId }: { userId: string }) {
const [isFollowing, setIsFollowing] = useState(false);
return (
<button onClick={() => setIsFollowing(!isFollowing)}>
{isFollowing ? 'Unfollow' : 'Follow'}
</button>
);
}
Podstawy Server Components
Czym są Server Components i dlaczego zostały wprowadzone?
Server Components to komponenty które renderują się wyłącznie na serwerze. Ich kod nigdy nie trafia do bundle JavaScript klienta.
Problemy które rozwiązują:
- Bundle size - w tradycyjnej architekturze cały kod komponentów trafia do klienta, nawet biblioteki używane tylko do formatowania danych
- Waterfall requests - klient musi najpierw pobrać JS, wykonać go, potem pobrać dane z API
- Duplikacja logiki - często piszemy API endpoint tylko po to żeby komponent mógł pobrać dane
- Bezpieczeństwo - sekrety (API keys, connection strings) muszą być ukryte za API
// Tradycyjna architektura
// 1. Klient pobiera bundle z komponentem
// 2. Komponent renderuje się, wywołuje useEffect
// 3. useEffect wywołuje fetch('/api/users')
// 4. API endpoint pobiera dane z bazy
// 5. Dane wracają do klienta, komponent re-renderuje
// Server Components
// 1. Serwer renderuje komponent z danymi
// 2. Klient otrzymuje gotowy HTML + RSC Payload
// Koniec - bez dodatkowych requestów, bez kodu komponentu w bundle
Server Component vs Client Component - jakie różnice?
| Cecha | Server Component | Client Component |
|---|---|---|
| Rendering | Tylko serwer | SSR + hydration na kliencie |
| Bundle | Nie w bundle | W bundle klienta |
| useState/useEffect | ❌ | ✅ |
| Event handlers | ❌ | ✅ |
| Browser APIs | ❌ | ✅ |
| async/await | ✅ bezpośrednio | ❌ (wymaga useEffect/hook) |
| Dostęp do DB/filesystem | ✅ | ❌ |
| Sekrety (env vars) | ✅ | ❌ (tylko NEXT_PUBLIC_) |
// Server Component - async, bezpośredni dostęp do danych
async function ProductList() {
const products = await db.products.findMany(); // Bezpośrednio z bazy
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
// Client Component - interaktywność
"use client";
function AddToCartButton({ productId }: { productId: string }) {
const [isAdding, setIsAdding] = useState(false);
const handleClick = async () => {
setIsAdding(true);
await addToCart(productId);
setIsAdding(false);
};
return (
<button onClick={handleClick} disabled={isAdding}>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
);
}
Jak działa dyrektywa "use client"?
"use client" na początku pliku oznacza że komponent jest Client Component. Tworzy "granicę" (boundary) między serwerem a klientem.
// components/Counter.tsx
"use client"; // Musi być na samym początku pliku
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
Ważne zasady:
- Granica propaguje w dół - wszystkie komponenty importowane przez Client Component są też Client Components
"use client";
// Te komponenty też będą Client Components
// nawet bez "use client" w ich plikach
import { ChildA } from './ChildA';
import { ChildB } from './ChildB';
- "use client" nie oznacza "tylko klient" - Client Components nadal są SSR dla pierwszego ładowania
- Nie można importować Server Component do Client Component - ale można przekazać jako children
// ❌ NIE DZIAŁA
"use client";
import { ServerComponent } from './ServerComponent';
function ClientComponent() {
return <ServerComponent />; // Error!
}
// ✅ DZIAŁA - Server Component jako children
// page.tsx (Server Component)
import { ClientComponent } from './ClientComponent';
import { ServerComponent } from './ServerComponent';
function Page() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
);
}
Co to jest RSC Payload?
RSC Payload to specjalny format danych który serwer wysyła do klienta. Zawiera:
- Wyrenderowany wynik Server Components
- Placeholdery gdzie powinny być Client Components
- Props dla Client Components
- Instrukcje jak złożyć całość
RSC Payload (uproszczony):
{
"type": "ServerComponent",
"props": {},
"children": [
{ "type": "h1", "children": "Hello World" },
{
"type": "ClientComponent",
"reference": "components/Counter.js",
"props": { "initialCount": 0 }
}
]
}
React na kliencie:
- Parsuje RSC Payload
- Renderuje statyczne części (Server Components)
- Ładuje i hydruje Client Components
- Łączy wszystko w jedno drzewo
Data Fetching w Server Components
Jak pobierać dane w Server Components?
Server Components mogą być async - używasz await bezpośrednio w komponencie:
// app/users/page.tsx
async function UsersPage() {
// Bezpośrednio w komponencie - bez useEffect, bez useState
const users = await fetch('https://api.example.com/users').then(r => r.json());
return (
<ul>
{users.map((user: User) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Bezpośredni dostęp do bazy:
import { prisma } from '@/lib/prisma';
async function ProductsPage() {
const products = await prisma.product.findMany({
where: { isActive: true },
orderBy: { createdAt: 'desc' },
take: 20
});
return <ProductList products={products} />;
}
Dostęp do systemu plików:
import { readFile } from 'fs/promises';
import path from 'path';
async function MarkdownPage({ slug }: { slug: string }) {
const filePath = path.join(process.cwd(), 'content', `${slug}.md`);
const content = await readFile(filePath, 'utf-8');
return <Markdown content={content} />;
}
Jak działa caching w Next.js z Server Components?
Next.js rozszerza fetch o automatyczne caching:
// Domyślnie: cache forever (static)
const data = await fetch('https://api.example.com/data');
// Revalidate co 60 sekund
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
});
// Bez cache (dynamic)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
});
// Cache z tagiem do manual revalidation
const data = await fetch('https://api.example.com/products', {
next: { tags: ['products'] }
});
// Revalidate tagu (np. po dodaniu produktu)
import { revalidateTag } from 'next/cache';
revalidateTag('products');
Dla nie-fetch data sources (Prisma, etc.):
import { unstable_cache } from 'next/cache';
const getProducts = unstable_cache(
async () => {
return await prisma.product.findMany();
},
['products'], // Cache key
{
revalidate: 60,
tags: ['products']
}
);
async function ProductsPage() {
const products = await getProducts();
return <ProductList products={products} />;
}
Jak obsługiwać równoległe requesty?
// ❌ Sekwencyjne - wolne
async function Dashboard() {
const user = await getUser();
const posts = await getPosts(); // Czeka na getUser
const comments = await getComments(); // Czeka na getPosts
return <DashboardUI user={user} posts={posts} comments={comments} />;
}
// ✅ Równoległe - szybkie
async function Dashboard() {
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments()
]);
return <DashboardUI user={user} posts={posts} comments={comments} />;
}
// ✅ Jeszcze lepiej - streaming z Suspense
async function Dashboard() {
const userPromise = getUser();
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserSection userPromise={userPromise} />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostsSection />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<CommentsSection />
</Suspense>
</div>
);
}
async function UserSection({ userPromise }: { userPromise: Promise<User> }) {
const user = await userPromise;
return <UserCard user={user} />;
}
Streaming i Suspense
Jak działa streaming z Server Components?
Streaming pozwala serwerowi wysyłać części strony jak tylko są gotowe, zamiast czekać na wszystko:
// app/page.tsx
import { Suspense } from 'react';
export default function Page() {
return (
<div>
{/* Natychmiast - statyczny content */}
<Header />
{/* Streamowany - pokazuje skeleton, potem dane */}
<Suspense fallback={<ProductsSkeleton />}>
<ProductsSection />
</Suspense>
{/* Streamowany niezależnie */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewsSection />
</Suspense>
{/* Natychmiast */}
<Footer />
</div>
);
}
async function ProductsSection() {
// Ta część jest streamowana
const products = await fetchProducts(); // Wolne API
return <ProductGrid products={products} />;
}
Jak to działa pod spodem:
- Serwer wysyła początkowy HTML z Header, Footer, i placeholdery (skeletons)
- Przeglądarka renderuje to natychmiast
- Gdy ProductsSection się załaduje, serwer wysyła HTML + JS który zamienia skeleton
- To samo dla ReviewsSection (niezależnie)
Nested Suspense boundaries
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails productId={params.id}>
{/* Nested Suspense - ładuje się po ProductDetails */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={params.id} />
</Suspense>
</ProductDetails>
</Suspense>
</div>
);
}
async function ProductDetails({
productId,
children
}: {
productId: string;
children: React.ReactNode
}) {
const product = await fetchProduct(productId);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{children}
</div>
);
}
Loading.tsx i Error.tsx w Next.js
Next.js automatycznie tworzy Suspense boundaries z plików specjalnych:
app/
products/
page.tsx # Strona
loading.tsx # Automatyczny Suspense fallback
error.tsx # Error boundary
not-found.tsx # 404 handling
// app/products/loading.tsx
export default function Loading() {
return <ProductsPageSkeleton />;
}
// app/products/error.tsx
"use client"; // Error boundary musi być Client Component
export default function Error({
error,
reset
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
Server Actions
Czym są Server Actions i jak działają?
Server Actions to funkcje async oznaczone "use server" które wykonują się na serwerze ale mogą być wywoływane z klienta:
// app/actions.ts
"use server";
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await prisma.post.create({
data: { title, content }
});
revalidatePath('/posts');
}
export async function deletePost(postId: string) {
await prisma.post.delete({
where: { id: postId }
});
revalidatePath('/posts');
}
Użycie w formularzach:
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
);
}
Server Actions w Client Components
"use client";
import { createPost } from '@/app/actions';
import { useFormStatus } from 'react-dom';
import { useActionState } from 'react';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
);
}
export function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<SubmitButton />
</form>
);
}
// Z useActionState dla obsługi błędów i stanu
export function CreatePostFormWithState() {
const [state, formAction, isPending] = useActionState(
async (prevState: any, formData: FormData) => {
try {
await createPost(formData);
return { success: true };
} catch (error) {
return { error: 'Failed to create post' };
}
},
null
);
return (
<form action={formAction}>
{state?.error && <p className="error">{state.error}</p>}
{state?.success && <p className="success">Post created!</p>}
<input name="title" required />
<textarea name="content" required />
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
</form>
);
}
Server Actions jako event handlers
"use client";
import { deletePost } from '@/app/actions';
import { useTransition } from 'react';
export function DeleteButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
startTransition(async () => {
await deletePost(postId);
});
};
return (
<button onClick={handleDelete} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
</button>
);
}
// Lub z bind dla przekazania argumentów
import { deletePost } from '@/app/actions';
export function DeleteButton({ postId }: { postId: string }) {
const deletePostWithId = deletePost.bind(null, postId);
return (
<form action={deletePostWithId}>
<button type="submit">Delete</button>
</form>
);
}
Walidacja i obsługa błędów w Server Actions
"use server";
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const PostSchema = z.object({
title: z.string().min(1, 'Title is required').max(100),
content: z.string().min(10, 'Content must be at least 10 characters')
});
export type ActionState = {
errors?: {
title?: string[];
content?: string[];
};
message?: string;
success?: boolean;
};
export async function createPost(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// Walidacja z Zod
const validatedFields = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content')
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Invalid fields'
};
}
try {
await prisma.post.create({
data: validatedFields.data
});
revalidatePath('/posts');
return { success: true, message: 'Post created!' };
} catch (error) {
return { message: 'Database error' };
}
}
"use client";
import { useActionState } from 'react';
import { createPost, ActionState } from '@/app/actions';
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState<ActionState, FormData>(
createPost,
{ errors: {} }
);
return (
<form action={formAction}>
<div>
<input name="title" />
{state.errors?.title && (
<p className="error">{state.errors.title[0]}</p>
)}
</div>
<div>
<textarea name="content" />
{state.errors?.content && (
<p className="error">{state.errors.content[0]}</p>
)}
</div>
{state.message && (
<p className={state.success ? 'success' : 'error'}>
{state.message}
</p>
)}
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Composition Patterns
Jak komponować Server i Client Components?
Pattern 1: Server Component z Client Component children
// app/page.tsx (Server Component)
import { ClientSidebar } from './ClientSidebar';
async function Page() {
const user = await getUser();
return (
<div className="layout">
<ClientSidebar user={user} />
<main>
<h1>Welcome, {user.name}</h1>
{/* Więcej Server Components */}
</main>
</div>
);
}
Pattern 2: Server Component jako children Client Component
// Modal.tsx (Client Component)
"use client";
import { useState } from 'react';
export function Modal({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Open</button>
{isOpen && (
<div className="modal">
{children} {/* Server Component może być tutaj */}
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</>
);
}
// page.tsx (Server Component)
import { Modal } from './Modal';
import { ServerContent } from './ServerContent';
function Page() {
return (
<Modal>
<ServerContent /> {/* Ten Server Component renderuje się na serwerze */}
</Modal>
);
}
Pattern 3: Slot pattern dla dynamicznego content
// Dashboard.tsx (Client Component)
"use client";
import { useState } from 'react';
export function Dashboard({
sidebar,
main,
stats
}: {
sidebar: React.ReactNode;
main: React.ReactNode;
stats: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="dashboard">
{!collapsed && <aside>{sidebar}</aside>}
<main>
<button onClick={() => setCollapsed(!collapsed)}>
Toggle Sidebar
</button>
{main}
</main>
<aside>{stats}</aside>
</div>
);
}
// page.tsx (Server Component)
import { Dashboard } from './Dashboard';
async function Page() {
return (
<Dashboard
sidebar={<ServerSidebar />}
main={<ServerMainContent />}
stats={<ServerStats />}
/>
);
}
Kiedy używać Server vs Client Components?
Używaj Server Components dla:
- Fetching danych
- Dostęp do backendu (DB, filesystem, API z sekretami)
- Ciężkie zależności (markdown parsers, syntax highlighters)
- Statyczny content
- SEO-critical content
Używaj Client Components dla:
- Interaktywność (onClick, onChange)
- State (useState, useReducer)
- Effects (useEffect, useLayoutEffect)
- Browser APIs (localStorage, geolocation)
- Custom hooks z state/effects
- React Class components
// ✅ Server Component - ciężka biblioteka nie trafia do bundle
import { marked } from 'marked';
import hljs from 'highlight.js';
async function MarkdownRenderer({ content }: { content: string }) {
const html = marked(content, {
highlight: (code, lang) => hljs.highlight(code, { language: lang }).value
});
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// ✅ Client Component - interaktywność
"use client";
function LikeButton({ postId }: { postId: string }) {
const [likes, setLikes] = useState(0);
return (
<button onClick={() => setLikes(l => l + 1)}>
❤️ {likes}
</button>
);
}
// ✅ Kombinacja
async function BlogPost({ slug }: { slug: string }) {
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<MarkdownRenderer content={post.content} />
<LikeButton postId={post.id} />
</article>
);
}
Props serialization między Server i Client Components
Props przekazywane z Server do Client Components muszą być serializowalne:
// ✅ Dozwolone
<ClientComponent
string="hello"
number={42}
boolean={true}
array={[1, 2, 3]}
object={{ name: "John" }}
date={date.toISOString()} // Jako string, nie Date object
/>
// ❌ Niedozwolone
<ClientComponent
function={() => console.log('hi')} // Funkcje nie są serializowalne
class={new MyClass()} // Instancje klas
symbol={Symbol('test')} // Symbole
date={new Date()} // Date objects
/>
// Rozwiązanie dla funkcji - Server Actions
"use server";
export async function handleAction(data: FormData) {
// ...
}
// Client Component może używać Server Action
"use client";
import { handleAction } from './actions';
function Form() {
return <form action={handleAction}>...</form>;
}
Performance i Optymalizacja
Jakie są korzyści performance z RSC?
- Mniejszy bundle JavaScript:
// Przed RSC: marked (37KB) + highlight.js (40KB) w bundle klienta
// Po RSC: 0KB w bundle - biblioteki są tylko na serwerze
async function CodeBlock({ code, language }: Props) {
// Te importy nie trafiają do klienta
const { marked } = await import('marked');
const hljs = await import('highlight.js');
const html = marked(code);
return <pre dangerouslySetInnerHTML={{ __html: html }} />;
}
- Szybszy initial load (TTFB, FCP):
- Mniej JS do pobrania i parsowania
- Streaming pozwala na progresywny rendering
- Dane pobierane na serwerze (blisko bazy danych)
- Automatyczny code splitting:
- Każdy Client Component to oddzielny chunk
- Ładowany tylko gdy potrzebny
- Lepsze caching:
- Server Components mogą być cache'owane na edge
- Static rendering dla stron bez dynamic data
Jak profilować i optymalizować RSC?
// 1. Identyfikuj wolne fetche
async function SlowComponent() {
console.time('fetch');
const data = await fetchData(); // Zmierz czas
console.timeEnd('fetch');
return <div>{data}</div>;
}
// 2. Używaj parallel fetching
async function Dashboard() {
// ❌ Sekwencyjne
const a = await fetchA();
const b = await fetchB();
// ✅ Równoległe
const [a, b] = await Promise.all([fetchA(), fetchB()]);
return <div>{a}{b}</div>;
}
// 3. Suspense dla perceived performance
function Page() {
return (
<div>
{/* Pokazuj cokolwiek natychmiast */}
<Header />
{/* Streamuj wolne części */}
<Suspense fallback={<Skeleton />}>
<SlowDataSection />
</Suspense>
</div>
);
}
// 4. Preload data
import { preload } from 'react-dom';
async function Page() {
// Zacznij ładować dane jak najwcześniej
preload('/api/data', { as: 'fetch' });
return <Content />;
}
Static vs Dynamic Rendering
// Static Rendering (domyślne) - generowane w build time
// Używane gdy nie ma dynamic danych
async function StaticPage() {
const posts = await getStaticPosts(); // Cache'd forever
return <PostList posts={posts} />;
}
// Dynamic Rendering - generowane per-request
// Wymuszone przez:
// - cookies(), headers()
// - searchParams
// - fetch z { cache: 'no-store' }
import { cookies } from 'next/headers';
async function DynamicPage() {
const session = cookies().get('session'); // Wymusza dynamic
const user = await getUser(session?.value);
return <UserDashboard user={user} />;
}
// Partial Prerendering (PPR) - hybrid
// Statyczne części + dynamic holes
export const experimental_ppr = true;
async function HybridPage() {
return (
<div>
<StaticHeader /> {/* Pre-rendered */}
<Suspense fallback={<Skeleton />}>
<DynamicUserSection /> {/* Streamowane */}
</Suspense>
<StaticFooter /> {/* Pre-rendered */}
</div>
);
}
Migracja i Adoption
Jak migrować z Pages Router do App Router?
// PRZED: pages/posts/[id].tsx
import { GetServerSideProps } from 'next';
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
const post = await fetchPost(params?.id as string);
return { props: { post } };
};
export default function PostPage({ post }: { post: Post }) {
return <PostContent post={post} />;
}
// PO: app/posts/[id]/page.tsx
async function PostPage({ params }: { params: { id: string } }) {
const post = await fetchPost(params.id);
return <PostContent post={post} />;
}
export default PostPage;
Stopniowa migracja:
- Utwórz folder
app/obokpages/ - Przenoś route po route
- Współistnieją - App Router ma priorytet
- Client Components oznacz
"use client"
Jak migrować useState/useEffect do Server Components?
// PRZED: Client Component z data fetching
"use client";
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then(setUser).finally(() => setLoading(false));
}, [userId]);
if (loading) return <Skeleton />;
if (!user) return <NotFound />;
return <UserCard user={user} />;
}
// PO: Server Component
async function UserProfile({ userId }: { userId: string }) {
const user = await fetchUser(userId);
if (!user) {
notFound(); // Next.js helper
}
return <UserCard user={user} />;
}
// Wrapper dla Suspense
function UserProfilePage({ userId }: { userId: string }) {
return (
<Suspense fallback={<Skeleton />}>
<UserProfile userId={userId} />
</Suspense>
);
}
Common mistakes przy używaniu RSC
// ❌ Używanie hooks w Server Component
async function ServerComponent() {
const [state, setState] = useState(0); // Error!
useEffect(() => {}, []); // Error!
return <div>{state}</div>;
}
// ❌ Bezpośredni import Server Component w Client Component
"use client";
import { ServerThing } from './ServerThing'; // Nie zadziała
function ClientComponent() {
return <ServerThing />; // Error!
}
// ✅ Przekaż jako children
function Parent() {
return (
<ClientComponent>
<ServerThing />
</ClientComponent>
);
}
// ❌ Przekazywanie nieserializowalnych props
async function Page() {
const handleClick = () => console.log('clicked');
return <ClientButton onClick={handleClick} />; // Error!
}
// ✅ Użyj Server Action
async function Page() {
async function handleClick() {
"use server";
console.log('clicked on server');
}
return <ClientButton onClick={handleClick} />;
}
// ❌ Używanie "use server" w komponencie
function Component() {
"use server"; // To nie działa - tylko w funkcjach async
return <div />;
}
// ✅ "use server" w osobnym pliku lub w async function
// actions.ts
"use server";
export async function myAction() { ... }
Zaawansowane Patterns
Optimistic Updates z Server Actions
"use client";
import { useOptimistic } from 'react';
import { addTodo } from '@/app/actions';
type Todo = { id: string; text: string; completed: boolean };
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
// Natychmiast pokaż nowy todo (optimistic)
addOptimisticTodo({
id: `temp-${Date.now()}`,
text,
completed: false
});
// Wyślij na serwer (w tle)
await addTodo(formData);
}
return (
<div>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
<form action={handleSubmit}>
<input name="text" required />
<button type="submit">Add</button>
</form>
</div>
);
}
Infinite Scroll z Server Components
// app/posts/page.tsx
import { Suspense } from 'react';
import { LoadMoreButton } from './LoadMoreButton';
async function PostsPage({
searchParams
}: {
searchParams: { page?: string }
}) {
const page = Number(searchParams.page) || 1;
const posts = await fetchPosts({ page, limit: 10 });
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
{posts.length === 10 && (
<LoadMoreButton currentPage={page} />
)}
</div>
);
}
// LoadMoreButton.tsx
"use client";
import { useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
export function LoadMoreButton({ currentPage }: { currentPage: number }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const loadMore = () => {
startTransition(() => {
router.push(`?page=${currentPage + 1}`, { scroll: false });
});
};
return (
<button onClick={loadMore} disabled={isPending}>
{isPending ? 'Loading...' : 'Load More'}
</button>
);
}
Real-time Updates z Server Components
// Polling approach
"use client";
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export function Poller({ interval = 5000 }: { interval?: number }) {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => {
router.refresh(); // Odświeża Server Components
}, interval);
return () => clearInterval(id);
}, [interval, router]);
return null;
}
// Użycie
async function NotificationsPage() {
const notifications = await getNotifications();
return (
<div>
<Poller interval={10000} />
<NotificationList notifications={notifications} />
</div>
);
}
// Alternatywa: Server-Sent Events w Client Component
"use client";
export function RealtimeNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
const eventSource = new EventSource('/api/notifications/stream');
eventSource.onmessage = (event) => {
const notification = JSON.parse(event.data);
setNotifications(prev => [notification, ...prev]);
};
return () => eventSource.close();
}, []);
return <NotificationList notifications={notifications} />;
}
Zadania Praktyczne
1. Zbuduj blog z Server Components
Wymagania:
- Lista postów (Server Component)
- Szczegóły posta z Markdown rendering (Server Component)
- Komentarze z paginacją (streaming)
- Formularz dodawania komentarza (Server Action)
- Like button (Client Component z optimistic update)
2. Dashboard z real-time data
Wymagania:
- Statystyki ładowane równolegle (Promise.all)
- Wykresy jako Client Components
- Auto-refresh co 30 sekund
- Loading states dla każdej sekcji
3. E-commerce product page
Wymagania:
- Product details (Server Component, cached)
- Related products (streaming)
- Add to cart (Server Action)
- Image gallery (Client Component)
- Reviews z infinite scroll
Podsumowanie
React Server Components to fundamentalna zmiana w architekturze React aplikacji. Na rozmowach rekrutacyjnych skup się na:
-
Podstawy - różnica Server vs Client Components,
"use client" - Data fetching - async components, caching, revalidation
- Streaming - Suspense boundaries, loading.tsx, parallel fetching
-
Server Actions -
"use server", form handling, mutations - Composition - patterns łączenia Server i Client Components
- Performance - bundle size, TTFB, static vs dynamic rendering
- Migracja - z Pages Router, z Client-side fetching
RSC zmieniają sposób myślenia o React - nie wszystko musi być interaktywne na kliencie. Domyślnie renderuj na serwerze, dodawaj "use client" tylko gdy potrzebujesz interaktywności.