Przejdź do treści
Frontend

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

Opublikowano:
·5 min czytania·Autor: MDS Software Solutions Group
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, useEffect i 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 roota app

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#

  1. Zacznij od prostych stron bez stanu
  2. Przenieś layouty wspólne do app/layout.tsx
  3. Migruj API Routes do Route Handlers (app/api/*/route.ts)
  4. 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#

  1. Domyślnie Server Components -- używaj 'use client' tylko tam, gdzie naprawdę potrzebujesz interaktywności.
  2. Przesuwaj Client Components w dół drzewa -- im mniejsza część aplikacji jest Client Component, tym mniejszy bundle.
  3. Równoległy data fetching -- używaj Promise.all() zamiast sekwencyjnych await.
  4. Granularne Suspense boundaries -- każda niezależna sekcja powinna mieć własny Suspense.
  5. Świadomy caching -- dobierz strategię rewalidacji do charakteru danych.
  6. Server Actions zamiast API Routes -- dla mutacji danych w ramach aplikacji.
  7. 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.

Autor
MDS Software Solutions Group

Zespół ekspertów programistycznych specjalizujących się w nowoczesnych technologiach webowych.

Next.js App Router i React Server Components - Kompletny Przewodnik | MDS Software Solutions Group | MDS Software Solutions Group