Next.js App Router i React Server Components - Kompletny Przewodnik

Next.js App Router i React Server Components - Kompletny Przewodnik
Next.js 13 wprowadził App Router -- nowy paradygmat budowania aplikacji oparty na React Server Components. Ta architektura fundamentalnie zmienia sposób, w jaki myślimy o renderowaniu, pobieraniu danych i organizacji kodu w aplikacjach React. W tym przewodniku szczegółowo omówimy każdy aspekt App Routera i pokażemy, jak wykorzystać jego pełny potencjał w produkcyjnych projektach.
App Router vs Pages Router -- co się zmieniło?#
Pages Router, oparty na katalogu pages/, przez lata był domyślnym modelem routingu w Next.js. App Router, korzystający z katalogu app/, wprowadza zupełnie nowe podejście.
Kluczowe różnice#
| Cecha | Pages Router | App Router |
|---|---|---|
| Katalog | pages/ | app/ |
| Renderowanie domyślne | Client Components | Server Components |
| Data fetching | getServerSideProps, getStaticProps | async komponenty |
| Layouty | Ręczne, per-strona | Zagnieżdżone, współdzielone |
| Ładowanie | Ręczne stany | loading.tsx |
| Błędy | _error.tsx | error.tsx per-segment |
| Metadane | Head component | Metadata API |
W App Routerze każda trasa jest definiowana przez strukturę folderów, a specjalne pliki (page.tsx, layout.tsx, loading.tsx, error.tsx) kontrolują zachowanie poszczególnych segmentów.
app/
├── layout.tsx # Root layout
├── page.tsx # Strona główna (/)
├── loading.tsx # Loading UI dla /
├── error.tsx # Error UI dla /
├── blog/
│ ├── layout.tsx # Layout bloga
│ ├── page.tsx # /blog
│ └── [slug]/
│ ├── page.tsx # /blog/:slug
│ └── loading.tsx # Loading dla posta
└── dashboard/
├── layout.tsx # Layout dashboardu
├── page.tsx # /dashboard
└── settings/
└── page.tsx # /dashboard/settings
Server Components vs Client Components#
React Server Components (RSC) to fundamentalna zmiana w architekturze React. W App Routerze wszystkie komponenty są domyślnie Server Components.
Server Components -- zalety i ograniczenia#
Server Components renderują się wyłącznie na serwerze. Ich kod JavaScript nigdy nie trafia do przeglądarki, co oznacza znaczną redukcję rozmiaru bundle'a.
// app/products/page.tsx
// To jest Server Component -- domyślnie w App Router
import { db } from '@/lib/database';
export default async function ProductsPage() {
// Bezpośredni dostęp do bazy danych -- bez API!
const products = await db.product.findMany({
where: { isPublished: true },
orderBy: { createdAt: 'desc' },
});
return (
<main>
<h1>Nasze Produkty</h1>
<div className="grid grid-cols-3 gap-6">
{products.map((product) => (
<article key={product.id} className="border rounded-lg p-4">
<h2 className="text-xl font-bold">{product.name}</h2>
<p className="text-gray-600">{product.description}</p>
<span className="text-2xl font-bold text-green-600">
{product.price.toFixed(2)} PLN
</span>
</article>
))}
</div>
</main>
);
}
Zalety Server Components:
- Bezpośredni dostęp do bazy danych, systemu plików i zasobów serwera
- Zero wpływu na rozmiar bundle'a klienta
- Automatyczne ukrywanie wrażliwych danych (klucze API, tokeny)
- Lepsza wydajność pierwszego ładowania (mniejszy JavaScript)
Ograniczenia Server Components:
- Brak dostępu do
useState,useEffecti innych hooków React - Brak obsługi zdarzeń przeglądarki (
onClick,onChange) - Brak dostępu do Web API przeglądarki (
window,localStorage)
Dyrektywa 'use client'#
Kiedy potrzebujesz interaktywności, użyj dyrektywy 'use client' na początku pliku. To granica, która mówi Next.js, że komponent (i jego dzieci) mają być renderowane również po stronie klienta.
'use client';
// app/components/AddToCartButton.tsx
import { useState, useTransition } from 'react';
import { addToCart } from '@/actions/cart';
interface AddToCartButtonProps {
productId: string;
productName: string;
}
export function AddToCartButton({ productId, productName }: AddToCartButtonProps) {
const [quantity, setQuantity] = useState(1);
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState('');
const handleAddToCart = () => {
startTransition(async () => {
const result = await addToCart(productId, quantity);
if (result.success) {
setMessage(`Dodano ${quantity}x ${productName} do koszyka!`);
}
});
};
return (
<div className="flex items-center gap-4">
<select
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
className="border rounded p-2"
>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<button
onClick={handleAddToCart}
disabled={isPending}
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? 'Dodawanie...' : 'Dodaj do koszyka'}
</button>
{message && <p className="text-green-600">{message}</p>}
</div>
);
}
Wzorzec kompozycji: Server + Client Components#
Najlepsza praktyka to utrzymywanie Client Components jak najniżej w drzewie komponentów. Server Component może renderować Client Component jako dziecko, przekazując mu dane przez propsy.
// app/products/[id]/page.tsx -- Server Component
import { db } from '@/lib/database';
import { AddToCartButton } from '@/components/AddToCartButton';
import { ProductGallery } from '@/components/ProductGallery';
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({
where: { id: params.id },
include: { images: true, reviews: true },
});
if (!product) {
notFound();
}
return (
<main className="max-w-6xl mx-auto py-8">
<div className="grid grid-cols-2 gap-8">
{/* Client Component -- interaktywna galeria */}
<ProductGallery images={product.images} />
<div>
<h1 className="text-3xl font-bold">{product.name}</h1>
<p className="text-gray-600 mt-4">{product.description}</p>
<p className="text-3xl font-bold text-green-600 mt-4">
{product.price.toFixed(2)} PLN
</p>
{/* Client Component -- interaktywny przycisk */}
<AddToCartButton
productId={product.id}
productName={product.name}
/>
</div>
</div>
{/* Server Component -- statyczna lista recenzji */}
<section className="mt-12">
<h2 className="text-2xl font-bold">Opinie ({product.reviews.length})</h2>
{product.reviews.map((review) => (
<div key={review.id} className="border-b py-4">
<p className="font-semibold">{review.author}</p>
<p>{review.content}</p>
</div>
))}
</section>
</main>
);
}
Pobieranie danych -- async Components#
W App Routerze data fetching jest radykalnie uproszczony. Zamiast specjalnych funkcji takich jak getServerSideProps, komponenty serwerowe mogą być async i bezpośrednio korzystać z await.
// app/dashboard/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
async function getAnalytics(userId: string) {
const res = await fetch(`${process.env.API_URL}/analytics/${userId}`, {
next: { revalidate: 300 }, // Rewalidacja co 5 minut
});
if (!res.ok) throw new Error('Nie udało się pobrać analityki');
return res.json();
}
async function getRecentOrders(userId: string) {
const res = await fetch(`${process.env.API_URL}/orders/${userId}`, {
next: { revalidate: 60 }, // Rewalidacja co minutę
});
if (!res.ok) throw new Error('Nie udało się pobrać zamówień');
return res.json();
}
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
if (!session) redirect('/login');
// Równoległe pobieranie danych
const [analytics, recentOrders] = await Promise.all([
getAnalytics(session.user.id),
getRecentOrders(session.user.id),
]);
return (
<div className="space-y-8">
<h1>Witaj, {session.user.name}!</h1>
<AnalyticsCards data={analytics} />
<RecentOrdersTable orders={recentOrders} />
</div>
);
}
Deduplikacja requestów#
Next.js automatycznie deduplikuje żądania fetch z tymi samymi argumentami w jednym cyklu renderowania. Jeśli wiele komponentów pobiera te same dane, request wykona się tylko raz.
// Te dwa komponenty w tym samym drzewie renderowania
// wykonają tylko JEDEN request do API
// app/components/Header.tsx
async function Header() {
const user = await fetch('/api/user'); // <- deduplikowany
return <nav>{user.name}</nav>;
}
// app/components/Sidebar.tsx
async function Sidebar() {
const user = await fetch('/api/user'); // <- deduplikowany
return <aside>{user.email}</aside>;
}
loading.tsx i error.tsx -- obsługa stanów#
Stany ładowania#
Plik loading.tsx automatycznie opakowuje stronę w React Suspense boundary, wyświetlając fallback podczas ładowania danych.
// app/blog/loading.tsx
export default function BlogLoading() {
return (
<div className="space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
</div>
))}
</div>
);
}
Obsługa błędów#
Plik error.tsx przechwytuje błędy w danym segmencie routingu i wyświetla fallback UI. Musi być Client Componentem, ponieważ korzysta z hooków.
'use client';
// app/blog/error.tsx
export default function BlogError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="text-center py-12">
<h2 className="text-2xl font-bold text-red-600">Coś poszło nie tak!</h2>
<p className="text-gray-600 mt-2">{error.message}</p>
<button
onClick={() => reset()}
className="mt-4 bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700"
>
Spróbuj ponownie
</button>
</div>
);
}
Parallel Routes i Intercepting Routes#
Parallel Routes (trasy równoległe)#
Parallel Routes pozwalają renderować wiele stron jednocześnie w tym samym layoucie. Definiuje się je za pomocą nazwanych slotów -- folderów zaczynających się od @.
app/dashboard/
├── layout.tsx
├── page.tsx
├── @analytics/
│ ├── page.tsx
│ └── loading.tsx
├── @activity/
│ ├── page.tsx
│ └── loading.tsx
└── @notifications/
└── page.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
activity,
notifications,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
activity: React.ReactNode;
notifications: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12 gap-6">
<main className="col-span-8">{children}</main>
<aside className="col-span-4 space-y-6">
{analytics}
{activity}
{notifications}
</aside>
</div>
);
}
Każdy slot ładuje się niezależnie, ma własny loading.tsx i error.tsx, co umożliwia niezależne strumieniowanie poszczególnych sekcji strony.
Intercepting Routes (przechwytywanie tras)#
Intercepting Routes pozwalają przechwycić nawigację do trasy i wyświetlić jej zawartość w kontekście bieżącej strony -- idealny wzorzec dla modali.
app/
├── feed/
│ ├── page.tsx
│ └── (..)photo/[id]/ # Przechwytuje /photo/[id]
│ └── page.tsx # Wyświetla jako modal
└── photo/[id]/
└── page.tsx # Pełna strona (bezpośredni dostęp / refresh)
Konwencje przechwytywania:
(.)-- ten sam poziom(..)-- jeden poziom wyżej(..)(..)-- dwa poziomy wyżej(...)-- od rootaapp
Server Actions#
Server Actions to funkcje asynchroniczne wykonywane na serwerze, wywoływane bezpośrednio z komponentów. Eliminują potrzebę tworzenia oddzielnych endpointów API dla mutacji danych.
// app/actions/contact.ts
'use server';
import { z } from 'zod';
import { db } from '@/lib/database';
import { revalidatePath } from 'next/cache';
const ContactSchema = z.object({
name: z.string().min(2, 'Imię musi mieć co najmniej 2 znaki'),
email: z.string().email('Nieprawidłowy adres email'),
message: z.string().min(10, 'Wiadomość musi mieć co najmniej 10 znaków'),
});
export type ContactFormState = {
errors?: Record<string, string[]>;
success?: boolean;
message?: string;
};
export async function submitContactForm(
prevState: ContactFormState,
formData: FormData
): Promise<ContactFormState> {
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
};
const validated = ContactSchema.safeParse(rawData);
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
success: false,
message: 'Popraw błędy w formularzu.',
};
}
try {
await db.contactMessage.create({
data: validated.data,
});
revalidatePath('/admin/messages');
return {
success: true,
message: 'Dziękujemy! Twoja wiadomość została wysłana.',
};
} catch (error) {
return {
success: false,
message: 'Wystąpił błąd. Spróbuj ponownie później.',
};
}
}
'use client';
// app/contact/ContactForm.tsx
import { useActionState } from 'react';
import { submitContactForm, type ContactFormState } from '@/actions/contact';
export function ContactForm() {
const [state, formAction, isPending] = useActionState<ContactFormState, FormData>(
submitContactForm,
{ success: false }
);
if (state.success) {
return (
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<p className="text-green-800 font-semibold">{state.message}</p>
</div>
);
}
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name" className="block font-medium">Imię</label>
<input
id="name"
name="name"
type="text"
className="w-full border rounded p-2 mt-1"
/>
{state.errors?.name && (
<p className="text-red-500 text-sm mt-1">{state.errors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="email" className="block font-medium">Email</label>
<input
id="email"
name="email"
type="email"
className="w-full border rounded p-2 mt-1"
/>
{state.errors?.email && (
<p className="text-red-500 text-sm mt-1">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="message" className="block font-medium">Wiadomość</label>
<textarea
id="message"
name="message"
rows={5}
className="w-full border rounded p-2 mt-1"
/>
{state.errors?.message && (
<p className="text-red-500 text-sm mt-1">{state.errors.message[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? 'Wysyłanie...' : 'Wyślij wiadomość'}
</button>
{state.message && !state.success && (
<p className="text-red-500">{state.message}</p>
)}
</form>
);
}
Streaming z Suspense#
Streaming pozwala na progresywne renderowanie UI -- szybkie wyświetlenie gotowych części strony, podczas gdy wolniejsze sekcje nadal się ładują.
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { RevenueChart } from '@/components/RevenueChart';
import { LatestOrders } from '@/components/LatestOrders';
import { TopProducts } from '@/components/TopProducts';
export default function DashboardPage() {
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold">Dashboard</h1>
{/* Ta sekcja ładuje się natychmiast */}
<div className="grid grid-cols-3 gap-4">
<StatCard title="Przychód" value="125,430 PLN" />
<StatCard title="Zamówienia" value="1,234" />
<StatCard title="Klienci" value="856" />
</div>
{/* Te sekcje streamują się niezależnie */}
<div className="grid grid-cols-2 gap-6">
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton rows={5} />}>
<LatestOrders />
</Suspense>
</div>
<Suspense fallback={<GridSkeleton />}>
<TopProducts />
</Suspense>
</div>
);
}
// Komponent asynchroniczny -- streamowany
async function RevenueChart() {
const data = await fetch('/api/analytics/revenue', {
next: { revalidate: 3600 },
}).then((r) => r.json());
return (
<div className="border rounded-lg p-6">
<h2 className="text-xl font-bold mb-4">Przychody</h2>
<Chart data={data} type="line" />
</div>
);
}
Caching i rewalidacja#
Next.js App Router oferuje wielopoziomowy system cache'owania.
Strategie cache'owania z fetch#
// Dane cache'owane na stałe (domyślnie)
const staticData = await fetch('https://api.example.com/config');
// Rewalidacja czasowa (ISR) -- co 60 sekund
const timedData = await fetch('https://api.example.com/products', {
next: { revalidate: 60 },
});
// Brak cache'owania -- zawsze świeże dane
const dynamicData = await fetch('https://api.example.com/stock', {
cache: 'no-store',
});
// Rewalidacja po tagu
const taggedData = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
Rewalidacja on-demand#
// app/actions/blog.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function publishPost(postId: string) {
await db.post.update({
where: { id: postId },
data: { status: 'published' },
});
// Rewaliduj konkretną ścieżkę
revalidatePath('/blog');
revalidatePath(`/blog/${postId}`);
// Lub rewaliduj po tagu
revalidateTag('posts');
}
Konfiguracja segmentu trasy#
// app/dashboard/page.tsx
// Wymuś dynamiczne renderowanie
export const dynamic = 'force-dynamic';
// Lub skonfiguruj rewalidację dla całego segmentu
export const revalidate = 30; // sekundy
Metadata API#
App Router wprowadza deklaratywny system zarządzania metadanymi, kluczowy dla SEO.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { db } from '@/lib/database';
// Statyczne metadane
export const metadata: Metadata = {
title: 'Blog',
};
// Lub dynamiczne metadane
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await db.post.findUnique({
where: { slug: params.slug },
});
if (!post) {
return { title: 'Post nie znaleziony' };
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
],
type: 'article',
publishedTime: post.publishedAt.toISOString(),
authors: [post.author.name],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
alternates: {
canonical: `https://example.com/blog/${post.slug}`,
},
};
}
Przewodnik migracji z Pages Router#
Migracja z Pages Router do App Router nie musi odbywać się od razu. Oba systemy mogą współistnieć w jednym projekcie.
Krok 1: Utwórz katalog app/ i root layout#
// app/layout.tsx
import { Inter } from 'next/font/google';
import '@/styles/globals.css';
const inter = Inter({ subsets: ['latin', 'latin-ext'] });
export const metadata = {
title: {
default: 'Moja Aplikacja',
template: '%s | Moja Aplikacja',
},
description: 'Opis aplikacji',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="pl">
<body className={inter.className}>
<Header />
{children}
<Footer />
</body>
</html>
);
}
Krok 2: Migruj data fetching#
// PRZED: pages/products.tsx
export async function getServerSideProps() {
const products = await fetchProducts();
return { props: { products } };
}
export default function ProductsPage({ products }) {
return <ProductList products={products} />;
}
// PO: app/products/page.tsx
export default async function ProductsPage() {
const products = await fetchProducts();
return <ProductList products={products} />;
}
Krok 3: Migruj stopniowo#
- Zacznij od prostych stron bez stanu
- Przenieś layouty wspólne do
app/layout.tsx - Migruj API Routes do Route Handlers (
app/api/*/route.ts) - Na koniec migruj strony z kompleksowym stanem
Krok 4: Route Handlers zamiast API Routes#
// PRZED: pages/api/products.ts
export default function handler(req, res) {
if (req.method === 'GET') {
const products = await db.product.findMany();
res.json(products);
}
}
// PO: app/api/products/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const products = await db.product.findMany();
return NextResponse.json(products);
}
export async function POST(request: Request) {
const body = await request.json();
const product = await db.product.create({ data: body });
return NextResponse.json(product, { status: 201 });
}
Najlepsze praktyki#
- Domyślnie Server Components -- używaj
'use client'tylko tam, gdzie naprawdę potrzebujesz interaktywności. - Przesuwaj Client Components w dół drzewa -- im mniejsza część aplikacji jest Client Component, tym mniejszy bundle.
- Równoległy data fetching -- używaj
Promise.all()zamiast sekwencyjnychawait. - Granularne Suspense boundaries -- każda niezależna sekcja powinna mieć własny
Suspense. - Świadomy caching -- dobierz strategię rewalidacji do charakteru danych.
- Server Actions zamiast API Routes -- dla mutacji danych w ramach aplikacji.
- Metadata API -- zawsze definiuj metadane dla SEO.
Podsumowanie#
App Router i React Server Components to przełomowa zmiana w ekosystemie React. Nowy model oferuje lepszą wydajność, prostszy data fetching, wbudowaną obsługę stanów ładowania i błędów, oraz zaawansowane wzorce routingu. Choć krzywa uczenia się jest stroma, korzyści -- mniejsze bundle'e, szybsze ładowanie i lepsza architektura -- w pełni to rekompensują.
Potrzebujesz pomocy z wdrożeniem Next.js App Router w swoim projekcie? Zespół MDS Software Solutions Group specjalizuje się w budowie wydajnych aplikacji webowych z wykorzystaniem najnowszych technologii. Skontaktuj się z nami, aby omówić, jak możemy przyspieszyć rozwój Twojego projektu i wdrożyć najlepsze praktyki od pierwszego dnia.
Zespół ekspertów programistycznych specjalizujących się w nowoczesnych technologiach webowych.