W nowoczesnym rozwoju aplikacji webowych wydajność i responsywność interfejsu użytkownika są kluczowymi czynnikami wpływającymi na doświadczenie użytkownika. Next.js, jako framework frontendowy, wprowadza zaawansowane techniki, które pomagają osiągnąć płynne doświadczenie użytkownika nawet przy złożonych operacjach pobierania danych. W tym module przyjrzymy się, jak Next.js App Router wykorzystuje streaming danych i React Suspense do tworzenia bardziej responsywnych aplikacji.
Przed przejściem do streamingu danych, warto zrozumieć ograniczenia tradycyjnych podejść do renderowania:
Te ograniczenia prowadzą do wolniejszego czasu do pierwszej treści (TTFB - Time To First Byte) i mogą powodować frustrację użytkowników, szczególnie na wolniejszych połączeniach lub przy skomplikowanych zapytaniach do bazy danych.
Streaming danych, wprowadzony w Next.js App Router, pozwala na stopniowe przesyłanie fragmentów UI z serwera do klienta w miarę ich gotowości, zamiast czekać na zakończenie wszystkich operacji pobierania danych.
Kluczowe zalety tego podejścia to:
Suspense to funkcja React wprowadzona w wersji 16.6, która umożliwia "zawieszenie" renderowania komponentu do czasu spełnienia określonego warunku (np. załadowania danych). Next.js App Router w pełni wykorzystuje tę funkcjonalność do implementacji streamingu danych.
Podstawową ideą Suspense jest:
Zacznijmy od podstawowego przykładu, który pokazuje, jak używać Suspense w Next.js:
1// app/dashboard/page.tsx
2import { Suspense } from 'react';
3import { UserProfile } from './user-profile';
4import { RecentOrders } from './recent-orders';
5import { Analytics } from './analytics';
6import { Loading } from '@/components/loading';
7
8export default function DashboardPage() {
9 return (
10 <div className="dashboard-container">
11 <h1>Panel użytkownika</h1>
12
13 {/* Natychmiast renderowany nagłówek */}
14 <div className="dashboard-header">
15 <Suspense fallback={<Loading />}>
16 <UserProfile />
17 </Suspense>
18 </div>
19
20 <div className="dashboard-grid">
21 {/* Sekcja zamówień z własnym stanem ładowania */}
22 <div className="orders-section">
23 <Suspense fallback={<Loading type="orders" />}>
24 <RecentOrders />
25 </Suspense>
26 </div>
27
28 {/* Sekcja analityki z własnym stanem ładowania */}
29 <div className="analytics-section">
30 <Suspense fallback={<Loading type="analytics" />}>
31 <Analytics />
32 </Suspense>
33 </div>
34 </div>
35 </div>
36 );
37}W tym przykładzie:
Suspense z odpowiednim fallbackiemKomponenty
UserProfile, RecentOrders i Analytics mogą asynchronicznie pobierać dane:1// app/dashboard/user-profile.tsx
2async function getUserData() {
3 // Pobieranie danych użytkownika - może zająć 500ms
4 const res = await fetch('/api/user');
5 return res.json();
6}
7
8export async function UserProfile() {
9 const user = await getUserData();
10
11 return (
12 <div className="user-profile">
13 <img src={user.avatar} alt={user.name} />
14 <h2>{user.name}</h2>
15 <p>{user.email}</p>
16 </div>
17 );
18}1// app/dashboard/recent-orders.tsx
2async function getRecentOrders() {
3 // Pobieranie ostatnich zamówień - może zająć 2s
4 const res = await fetch('/api/orders/recent');
5 return res.json();
6}
7
8export async function RecentOrders() {
9 const orders = await getRecentOrders();
10
11 return (
12 <div>
13 <h3>Ostatnie zamówienia</h3>
14 <table>
15 <thead>
16 <tr>
17 <th>ID</th>
18 <th>Produkt</th>
19 <th>Cena</th>
20 <th>Status</th>
21 </tr>
22 </thead>
23 <tbody>
24 {orders.map(order => (
25 <tr key={order.id}>
26 <td>{order.id}</td>
27 <td>{order.product}</td>
28 <td>{order.price} zł</td>
29 <td>{order.status}</td>
30 </tr>
31 ))}
32 </tbody>
33 </table>
34 </div>
35 );
36}Możemy tworzyć bardziej złożone struktury z zagnieżdżonymi komponentami Suspense:
1// app/products/[category]/page.tsx
2import { Suspense } from 'react';
3import { CategoryHeader } from './category-header';
4import { ProductGrid } from './product-grid';
5import { ProductFilter } from './product-filter';
6import { RelatedCategories } from './related-categories';
7import { SimilarProducts } from './similar-products';
8import { Loading } from '@/components/loading';
9
10export default async function CategoryPage({ params }) {
11 return (
12 <div className="category-page">
13 {/* Zagnieżdżone komponenty Suspense */}
14 <Suspense fallback={<Loading height="200px" />}>
15 <CategoryHeader category={(await params).category} />
16
17 <div className="product-layout">
18 <aside className="filters">
19 <Suspense fallback={<Loading type="filters" />}>
20 <ProductFilter category={(await params).category} />
21 </Suspense>
22 </aside>
23
24 <main className="products">
25 <Suspense fallback={<Loading type="products" count={12} />}>
26 <ProductGrid category={(await params).category} />
27 </Suspense>
28 </main>
29 </div>
30
31 <div className="recommendations">
32 <Suspense fallback={<Loading type="categories" />}>
33 <RelatedCategories category={(await params).category} />
34 </Suspense>
35
36 <Suspense fallback={<Loading type="products" />}>
37 <SimilarProducts category={(await params).category} />
38 </Suspense>
39 </div>
40 </Suspense>
41 </div>
42 );
43}W tym przykładzie:
Suspense obejmuje cały widok kategoriiSuspense dzielą stronę na mniejsze, niezależnie ładowane sekcjeMożemy łączyć streaming z komponentami klienta, co daje nam możliwość tworzenia interaktywnych UI z płynnym ładowaniem:
1// app/products/[id]/page.tsx
2import { Suspense } from 'react';
3import { ProductDetails } from './product-details';
4import { ProductReviews } from './product-reviews';
5import AddToCartButton from './add-to-cart-button'; // Komponent klienta
6import ProductImageGallery from './product-image-gallery'; // Komponent klienta
7import { Loading } from '@/components/loading';
8
9export default async function ProductPage({ params }) {
10 return (
11 <div className="product-page">
12 <div className="product-main">
13 <Suspense fallback={<Loading type="image-gallery" />}>
14 <ProductImageWrapper productId={(await params).id} />
15 </Suspense>
16
17 <div className="product-info">
18 <Suspense fallback={<Loading type="product-details" />}>
19 <ProductDetailsWrapper productId={(await params).id} />
20 </Suspense>
21
22 {/* Komponent klienta dla interaktywności */}
23 <AddToCartButton productId={(await params).id} />
24 </div>
25 </div>
26
27 <div className="product-reviews">
28 <Suspense fallback={<Loading type="reviews" />}>
29 <ProductReviews productId={(await params).id} />
30 </Suspense>
31 </div>
32 </div>
33 );
34}
35
36// Wrapper komponentów, które potrzebują props
37async function ProductImageWrapper({ productId }) {
38 const product = await getProduct(productId);
39 return <ProductImageGallery images={product.images} />;
40}
41
42async function ProductDetailsWrapper({ productId }) {
43 const product = await getProduct(productId);
44 return <ProductDetails product={product} />;
45}
46
47async function getProduct(id) {
48 const res = await fetch(`/api/products/${id}`);
49 return res.json();
50}W tym przykładzie:
ProductImageGallery i AddToCartButton to komponenty klienta ('use client')AddToCartButton jest interaktywny od razu po załadowaniu, nawet gdy inne części strony są wciąż w trakcie ładowaniaJedną z najlepszych praktyk przy streamingu danych jest paralelizacja żądań, aby uniknąć wodospadowego pobierania danych:
1// app/dashboard/page.tsx
2import { Suspense } from 'react';
3import { UserProfile } from './user-profile';
4import { RecentOrders } from './recent-orders';
5import { Analytics } from './analytics';
6import { Loading } from '@/components/loading';
7
8// Preinicjalizacja zapytań - unikanie wodospadu danych
9export function generateMetadata() {
10 // Inicjujemy pobieranie danych wcześnie, aby były gotowe
11 // gdy komponenty będą ich potrzebować
12 preloadUserData();
13 preloadRecentOrders();
14 preloadAnalytics();
15
16 return {
17 title: 'Dashboard - Moja Aplikacja',
18 };
19}
20
21// Funkcje preinicjalizujące
22function preloadUserData() {
23 void getUserData(); // void ignoruje Promise
24}
25
26function preloadRecentOrders() {
27 void getRecentOrders(); // void ignoruje Promise
28}
29
30function preloadAnalytics() {
31 void getAnalyticsData(); // void ignoruje Promise
32}
33
34// Funkcje pobierające dane z cache
35async function getUserData() {
36 const res = await fetch('/api/user', { cache: 'force-cache' });
37 return res.json();
38}
39
40async function getRecentOrders() {
41 const res = await fetch('/api/orders/recent', { cache: 'force-cache' });
42 return res.json();
43}
44
45async function getAnalyticsData() {
46 const res = await fetch('/api/analytics', { cache: 'force-cache' });
47 return res.json();
48}
49
50export default function DashboardPage() {
51 return (
52 <div className="dashboard-container">
53 {/* ... struktura strony z komponentami Suspense, jak wcześniej ... */}
54 </div>
55 );
56}W tym podejściu:
generateMetadata, zanim komponenty ich zażądająvoid do ignorowania Promise, ponieważ nie potrzebujemy ich rezultatu w tej funkcjiMożemy strategicznie priorytetyzować ładowanie treści, aby najważniejsze elementy były wyświetlane jako pierwsze:
1// app/article/[slug]/page.tsx
2import { Suspense } from 'react';
3import { ArticleHeader } from './article-header';
4import { ArticleContent } from './article-content';
5import { ArticleComments } from './article-comments';
6import { RelatedArticles } from './related-articles';
7import { AuthorBio } from './author-bio';
8import { SocialSharing } from './social-sharing';
9import { Loading } from '@/components/loading';
10
11export default async function ArticlePage({ params }) {
12 return (
13 <article className="article-container">
14 {/* Priorytet 1: Nagłówek artykułu - ładuje się pierwszy */}
15 <Suspense fallback={<Loading type="header" />}>
16 <ArticleHeader slug={(await params).slug} />
17 </Suspense>
18
19 <div className="article-grid">
20 <div className="article-main">
21 {/* Priorytet 2: Treść artykułu */}
22 <Suspense fallback={<Loading type="content" />}>
23 <ArticleContent slug={(await params).slug} />
24
25 <div className="article-footer">
26 {/* Priorytet 3: Autor i udostępnianie - ładują się po treści */}
27 <Suspense fallback={<Loading type="author" />}>
28 <AuthorBio slug={(await params).slug} />
29 </Suspense>
30
31 <SocialSharing url={`/article/${(await params).slug}`} />
32 </div>
33 </Suspense>
34 </div>
35
36 <aside className="article-sidebar">
37 {/* Priorytet 4: Powiązane artykuły - ładują się na końcu */}
38 <Suspense fallback={<Loading type="related" />}>
39 <RelatedArticles slug={(await params).slug} />
40 </Suspense>
41 </aside>
42 </div>
43
44 {/* Priorytet 5: Komentarze - ładują się na samym końcu */}
45 <Suspense fallback={<Loading type="comments" />}>
46 <ArticleComments slug={(await params).slug} />
47 </Suspense>
48 </article>
49 );
50}W tym przykładzie:
Czasami potrzebujemy, aby jeden komponent poczekał na dane z innego komponentu. Możemy to zrealizować za pomocą kontekstu i współdzielonych zasobów:
1// app/shop/[category]/[subcategory]/page.tsx
2import { Suspense } from 'react';
3import { CategoryProvider } from './category-context';
4import { CategoryInfo } from './category-info';
5import { SubcategoryProducts } from './subcategory-products';
6import { Loading } from '@/components/loading';
7
8export default async function SubcategoryPage({ params }) {
9 const { category, subcategory } = await params;
10
11 return (
12 <div className="shop-page">
13 <CategoryProvider category={category}>
14 <Suspense fallback={<Loading type="category" />}>
15 <CategoryInfo />
16
17 {/* Ten komponent potrzebuje danych z CategoryInfo */}
18 <Suspense fallback={<Loading type="products" />}>
19 <SubcategoryProducts subcategory={subcategory} />
20 </Suspense>
21 </Suspense>
22 </CategoryProvider>
23 </div>
24 );
25}1// app/shop/[category]/[subcategory]/category-context.tsx
2'use client';
3
4import { createContext, useContext, useState, useEffect } from 'react';
5
6const CategoryContext = createContext(null);
7
8export function CategoryProvider({ children, category }) {
9 const [categoryData, setCategoryData] = useState(null);
10 const [loading, setLoading] = useState(true);
11
12 useEffect(() => {
13 async function loadCategoryData() {
14 try {
15 const res = await fetch(`/api/categories/${category}`);
16 const data = await res.json();
17 setCategoryData(data);
18 } catch (error) {
19 console.error('Error loading category data:', error);
20 } finally {
21 setLoading(false);
22 }
23 }
24
25 loadCategoryData();
26 }, [category]);
27
28 return (
29 <CategoryContext.Provider value={{ categoryData, loading }}>
30 {children}
31 </CategoryContext.Provider>
32 );
33}
34
35export function useCategoryContext() {
36 const context = useContext(CategoryContext);
37 if (!context) {
38 throw new Error('useCategoryContext must be used within a CategoryProvider');
39 }
40 return context;
41}W komponentach odbierających dane:
1// app/shop/[category]/[subcategory]/category-info.tsx
2'use client';
3
4import { useCategoryContext } from './category-context';
5
6export function CategoryInfo() {
7 const { categoryData, loading } = useCategoryContext();
8
9 if (loading) {
10 return <div>Ładowanie informacji o kategorii...</div>;
11 }
12
13 return (
14 <div className="category-info">
15 <h1>{categoryData.name}</h1>
16 <p>{categoryData.description}</p>
17 <div className="category-stats">
18 <span>{categoryData.productCount} produktów</span>
19 <span>{categoryData.brandsCount} marek</span>
20 </div>
21 </div>
22 );
23}1// app/shop/[category]/[subcategory]/subcategory-products.tsx
2'use client';
3
4import { useCategoryContext } from './category-context';
5import { useEffect, useState } from 'react';
6
7export function SubcategoryProducts({ subcategory }) {
8 const { categoryData } = useCategoryContext();
9 const [products, setProducts] = useState([]);
10 const [loading, setLoading] = useState(true);
11
12 useEffect(() => {
13 // Czekamy, aż categoryData będzie dostępne
14 if (!categoryData) return;
15
16 async function loadProducts() {
17 try {
18 // Używamy categoryData do pobierania produktów
19 const res = await fetch(
20 `/api/products?category=${categoryData.id}&subcategory=${subcategory}`
21 );
22 const data = await res.json();
23 setProducts(data);
24 } catch (error) {
25 console.error('Error loading products:', error);
26 } finally {
27 setLoading(false);
28 }
29 }
30
31 loadProducts();
32 }, [categoryData, subcategory]);
33
34 if (loading) {
35 return <div>Ładowanie produktów...</div>;
36 }
37
38 return (
39 <div className="products-grid">
40 {products.map(product => (
41 <div key={product.id} className="product-card">
42 <img src={product.image} alt={product.name} />
43 <h3>{product.name}</h3>
44 <p>{product.price} zł</p>
45 </div>
46 ))}
47 </div>
48 );
49}Przy implementacji streamingu danych w Next.js App Router, ważne jest zrozumienie różnic między komponentami serwera i klienta:
Komponenty serwera są idealne do streamingu, ponieważ:
Suspense dla obsługi stanu ładowania1// app/products/product-list.tsx (Server Component)
2async function getProducts() {
3 // Bezpośredni dostęp do bazy danych na serwerze
4 const products = await db.products.findMany();
5 return products;
6}
7
8export async function ProductList() {
9 const products = await getProducts();
10
11 return (
12 <div className="product-list">
13 {products.map(product => (
14 <div key={product.id} className="product-item">
15 <h3>{product.name}</h3>
16 <p>{product.price} zł</p>
17 </div>
18 ))}
19 </div>
20 );
21}Komponenty klienta są potrzebne dla interaktywności, ale mają inne zachowanie w kontekście streamingu:
1// app/products/filter-panel.tsx (Client Component)
2'use client';
3
4import { useState, useEffect } from 'react';
5
6export function FilterPanel({ onFilterChange }) {
7 const [categories, setCategories] = useState([]);
8 const [loading, setLoading] = useState(true);
9 const [selectedCategory, setSelectedCategory] = useState(null);
10
11 useEffect(() => {
12 async function loadCategories() {
13 try {
14 const res = await fetch('/api/categories');
15 const data = await res.json();
16 setCategories(data);
17 } catch (error) {
18 console.error('Error loading categories:', error);
19 } finally {
20 setLoading(false);
21 }
22 }
23
24 loadCategories();
25 }, []);
26
27 function handleCategoryChange(categoryId) {
28 setSelectedCategory(categoryId);
29 onFilterChange({ category: categoryId });
30 }
31
32 if (loading) return <div>Ładowanie filtrów...</div>;
33
34 return (
35 <div className="filter-panel">
36 <h3>Kategorie</h3>
37 <ul>
38 {categories.map(category => (
39 <li key={category.id}>
40 <button
41 onClick={() => handleCategoryChange(category.id)}
42 className={selectedCategory === category.id ? 'active' : ''}
43 >
44 {category.name}
45 </button>
46 </li>
47 ))}
48 </ul>
49 </div>
50 );
51}Najlepszą praktyką jest używanie Server Components do pobierania danych i przygotowywania zawartości, a Client Components do interaktywności:
1// app/products/page.tsx
2import { Suspense } from 'react';
3import { ProductList } from './product-list'; // Server Component
4import FilterPanel from './filter-panel'; // Client Component
5import { Loading } from '@/components/loading';
6
7export default function ProductsPage() {
8 return (
9 <div className="products-page">
10 <div className="sidebar">
11 <FilterPanel onFilterChange={/* obsługa po stronie klienta */} />
12 </div>
13
14 <div className="main-content">
15 <Suspense fallback={<Loading type="products" />}>
16 <ProductList />
17 </Suspense>
18 </div>
19 </div>
20 );
21}Podczas streamingu danych, istotna jest odpowiednia obsługa błędów. Next.js App Router dostarcza komponent
Error Boundary do obsługi błędów w strumieniowanych komponentach:1// app/dashboard/error.tsx
2'use client';
3
4import { useEffect } from 'react';
5
6export default function Error({
7 error,
8 reset
9}: {
10 error: Error & { digest?: string };
11 reset: () => void;
12}) {
13 useEffect(() => {
14 // Logowanie błędu do serwisu monitorującego
15 console.error('Dashboard error:', error);
16 }, [error]);
17
18 return (
19 <div className="error-container">
20 <h2>Coś poszło nie tak!</h2>
21 <p>{error.message || 'Wystąpił nieoczekiwany błąd.'}</p>
22 <button
23 onClick={() => reset()}
24 className="button"
25 >
26 Spróbuj ponownie
27 </button>
28 </div>
29 );
30}Możemy także tworzyć komponent Error Boundary dla konkretnych sekcji:
1// app/dashboard/analytics-error.tsx
2'use client';
3
4export default function AnalyticsError({ error, reset }) {
5 return (
6 <div className="section-error">
7 <h3>Błąd ładowania analityki</h3>
8 <p>Nie mogliśmy załadować danych analitycznych: {error.message}</p>
9 <button onClick={reset}>Spróbuj ponownie</button>
10 </div>
11 );
12}A następnie używać go dla konkretnych sekcji:
1// app/dashboard/page.tsx
2import { Suspense } from 'react';
3import { ErrorBoundary } from 'react-error-boundary';
4import { UserProfile } from './user-profile';
5import { RecentOrders } from './recent-orders';
6import { Analytics } from './analytics';
7import AnalyticsError from './analytics-error';
8import { Loading } from '@/components/loading';
9
10export default function DashboardPage() {
11 return (
12 <div className="dashboard-container">
13 {/* ... inne sekcje ... */}
14
15 <div className="analytics-section">
16 <ErrorBoundary FallbackComponent={AnalyticsError}>
17 <Suspense fallback={<Loading type="analytics" />}>
18 <Analytics />
19 </Suspense>
20 </ErrorBoundary>
21 </div>
22 </div>
23 );
24}Właściwa implementacja streamingu danych może znacząco poprawić wydajność aplikacji. Oto kilka dodatkowych technik optymalizacyjnych:
1// app/layout.tsx
2export default function RootLayout({ children }) {
3 return (
4 <html lang="pl">
5 <body>
6 {/* Ustawienie minimalnego opóźnienia dla Suspense */}
7 <script dangerouslySetInnerHTML={{ __html: `
8 window.SuspenseMinDelay = 300; // ms
9 ` }} />
10 {children}
11 </body>
12 </html>
13 );
14}Możemy również stworzyć własny komponent do opóźniania stanu ładowania:
1// components/delayed-suspense.tsx
2'use client';
3
4import { Suspense, useState, useEffect } from 'react';
5
6export function DelayedSuspense({
7 children,
8 fallback,
9 delay = 300
10}) {
11 const [showFallback, setShowFallback] = useState(false);
12
13 useEffect(() => {
14 const timer = setTimeout(() => {
15 setShowFallback(true);
16 }, delay);
17
18 return () => clearTimeout(timer);
19 }, [delay]);
20
21 return (
22 <Suspense fallback={showFallback ? fallback : null}>
23 {children}
24 </Suspense>
25 );
26}1// app/products/[id]/page.tsx
2import { Suspense } from 'react';
3import { ProductBasicInfo } from './product-basic-info'; // Krytyczne dane
4import { ProductDetails } from './product-details'; // Ważne dane
5import { ProductReviews } from './product-reviews'; // Mniej krytyczne dane
6import { RelatedProducts } from './related-products'; // Najmniej krytyczne dane
7import { Loading } from '@/components/loading';
8
9export default async function ProductPage({ params }) {
10 // Preinicjalizacja krytycznych danych
11 preloadProductBasicInfo((await params).id);
12
13 return (
14 <div className="product-page">
15 {/* Natychmiastowe renderowanie krytycznych informacji */}
16 <div className="product-hero">
17 <Suspense fallback={<Loading priority="high" />}>
18 <ProductBasicInfo productId={(await params).id} />
19 </Suspense>
20 </div>
21
22 {/* Mniej krytyczne sekcje ładowane później */}
23 <div className="product-content">
24 <Suspense fallback={<Loading priority="medium" />}>
25 <ProductDetails productId={(await params).id} />
26 </Suspense>
27 </div>
28
29 <div className="product-secondary">
30 <Suspense fallback={<Loading priority="low" />}>
31 <ProductReviews productId={(await params).id} />
32 </Suspense>
33 </div>
34
35 <div className="product-related">
36 <Suspense fallback={<Loading priority="lowest" />}>
37 <RelatedProducts productId={(await params).id} />
38 </Suspense>
39 </div>
40 </div>
41 );
42}
43
44// Preinicjalizacja krytycznych danych
45function preloadProductBasicInfo(id) {
46 void getProductBasicInfo(id);
47}
48
49async function getProductBasicInfo(id) {
50 const res = await fetch(`/api/products/${id}/basic`, {
51 priority: 'high' // Ustawienie priorytetu fetch API
52 });
53 return res.json();
54}1// components/loading.tsx
2export function Loading({ type, priority = 'medium', height, width }) {
3 // Określamy wymiary szkieletu na podstawie typu zawartości
4 const dimensions = getDimensionsForType(type, { height, width });
5
6 return (
7 <div
8 className={`skeleton-loader ${type} priority-${priority}`}
9 style={{
10 height: dimensions.height,
11 width: dimensions.width,
12 // Predefiniowane wymiary zapobiegają przeskokom layoutu
13 }}
14 >
15 <div className="skeleton-animation" />
16 </div>
17 );
18}
19
20function getDimensionsForType(type, userDimensions) {
21 // Domyślne wymiary dla różnych typów treści
22 const defaults = {
23 'header': { height: '100px', width: '100%' },
24 'products': { height: '300px', width: '100%' },
25 'sidebar': { height: '400px', width: '250px' },
26 // ... inne typy
27 };
28
29 return {
30 height: userDimensions.height || defaults[type]?.height || '200px',
31 width: userDimensions.width || defaults[type]?.width || '100%'
32 };
33}Streaming danych jest szczególnie przydatny w następujących scenariuszach:
Streaming danych i React Suspense w Next.js App Router oferują potężne narzędzia do tworzenia wydajniejszych i bardziej responsywnych aplikacji. Kluczowe zalety tego podejścia to:
Implementując streaming danych w swoich aplikacjach Next.js, pamiętaj o następujących najlepszych praktykach:
W następnym module omówimy szczegółowo, jak tworzyć zaawansowane szkielety ładowania i wskaźniki postępu, które uzupełniają strategię streamingu danych, zapewniając jeszcze lepsze doświadczenie użytkownika.