In modern web application development, performance and user interface responsiveness are key factors affecting user experience. Next.js, as a frontend framework, introduces advanced techniques that help achieve a smooth user experience even with complex data fetching operations. In this module, we will look at how Next.js App Router uses data streaming and React Suspense to create more responsive applications.
Before moving on to data streaming, it's worth understanding the limitations of traditional rendering approaches:
These limitations lead to slower Time To First Byte (TTFB) and can cause user frustration, especially on slower connections or with complex database queries.
Data streaming, introduced in the Next.js App Router, allows for progressively sending UI fragments from the server to the client as they become ready, instead of waiting for all data fetching operations to complete.
Key advantages of this approach include:
Suspense is a React feature introduced in version 16.6 that allows "suspending" component rendering until a certain condition is met (e.g., data has loaded). Next.js App Router fully leverages this functionality to implement data streaming.
The fundamental idea of Suspense is:
Let's start with a basic example showing how to use Suspense in 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>User Dashboard</h1>
12
13 {/* Immediately rendered header */}
14 <div className="dashboard-header">
15 <Suspense fallback={<Loading />}>
16 <UserProfile />
17 </Suspense>
18 </div>
19
20 <div className="dashboard-grid">
21 {/* Orders section with its own loading state */}
22 <div className="orders-section">
23 <Suspense fallback={<Loading type="orders" />}>
24 <RecentOrders />
25 </Suspense>
26 </div>
27
28 {/* Analytics section with its own loading state */}
29 <div className="analytics-section">
30 <Suspense fallback={<Loading type="analytics" />}>
31 <Analytics />
32 </Suspense>
33 </div>
34 </div>
35 </div>
36 );
37}In this example:
Suspense component with an appropriate fallbackThe
UserProfile, RecentOrders, and Analytics components can asynchronously fetch data:1// app/dashboard/user-profile.tsx
2async function getUserData() {
3 // Fetching user data - may take 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 // Fetching recent orders - may take 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>Recent Orders</h3>
14 <table>
15 <thead>
16 <tr>
17 <th>ID</th>
18 <th>Product</th>
19 <th>Price</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}</td>
29 <td>{order.status}</td>
30 </tr>
31 ))}
32 </tbody>
33 </table>
34 </div>
35 );
36}We can create more complex structures with nested Suspense components:
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 {/* Nested Suspense components */}
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}In this example:
Suspense wraps the entire category viewSuspense components split the page into smaller, independently loading sectionsWe can combine streaming with client components, giving us the ability to create interactive UIs with smooth loading:
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'; // Client component
6import ProductImageGallery from './product-image-gallery'; // Client component
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 {/* Client component for interactivity */}
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 components that need 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}In this example:
ProductImageGallery and AddToCartButton are client components ('use client')AddToCartButton component is interactive immediately after loading, even when other parts of the page are still loadingOne of the best practices for data streaming is parallelizing requests to avoid waterfall data fetching:
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// Pre-initializing queries - avoiding the data waterfall
9export function generateMetadata() {
10 // We initialize data fetching early so it's ready
11 // when components need it
12 preloadUserData();
13 preloadRecentOrders();
14 preloadAnalytics();
15
16 return {
17 title: 'Dashboard - My Application',
18 };
19}
20
21// Pre-initialization functions
22function preloadUserData() {
23 void getUserData(); // void ignores the Promise
24}
25
26function preloadRecentOrders() {
27 void getRecentOrders(); // void ignores the Promise
28}
29
30function preloadAnalytics() {
31 void getAnalyticsData(); // void ignores the Promise
32}
33
34// Data fetching functions with 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 {/* ... page structure with Suspense components, as before ... */}
54 </div>
55 );
56}In this approach:
generateMetadata function, before components request itvoid to ignore the Promise, since we don't need its result in this functionWe can strategically prioritize content loading so the most important elements are displayed first:
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 {/* Priority 1: Article header - loads first */}
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 {/* Priority 2: Article content */}
22 <Suspense fallback={<Loading type="content" />}>
23 <ArticleContent slug={(await params).slug} />
24
25 <div className="article-footer">
26 {/* Priority 3: Author and sharing - loads after content */}
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 {/* Priority 4: Related articles - loads at the end */}
38 <Suspense fallback={<Loading type="related" />}>
39 <RelatedArticles slug={(await params).slug} />
40 </Suspense>
41 </aside>
42 </div>
43
44 {/* Priority 5: Comments - loads last */}
45 <Suspense fallback={<Loading type="comments" />}>
46 <ArticleComments slug={(await params).slug} />
47 </Suspense>
48 </article>
49 );
50}In this example:
Sometimes we need one component to wait for data from another component. We can achieve this using context and shared resources:
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 {/* This component needs data from 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}In the components receiving data:
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>Loading category information...</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} products</span>
19 <span>{categoryData.brandsCount} brands</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 // Wait until categoryData is available
14 if (!categoryData) return;
15
16 async function loadProducts() {
17 try {
18 // Use categoryData to fetch products
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>Loading products...</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}</p>
45 </div>
46 ))}
47 </div>
48 );
49}When implementing data streaming in Next.js App Router, it's important to understand the differences between server and client components:
Server components are ideal for streaming because:
Suspense for loading state handling1// app/products/product-list.tsx (Server Component)
2async function getProducts() {
3 // Direct database access on the server
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}</p>
17 </div>
18 ))}
19 </div>
20 );
21}Client components are needed for interactivity but have different behavior in the context of streaming:
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>Loading filters...</div>;
33
34 return (
35 <div className="filter-panel">
36 <h3>Categories</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}The best practice is to use Server Components for fetching data and preparing content, and Client Components for interactivity:
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={/* client-side handling */} />
12 </div>
13
14 <div className="main-content">
15 <Suspense fallback={<Loading type="products" />}>
16 <ProductList />
17 </Suspense>
18 </div>
19 </div>
20 );
21}During data streaming, proper error handling is essential. Next.js App Router provides an
Error Boundary component for handling errors in streamed components: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 // Log the error to a monitoring service
15 console.error('Dashboard error:', error);
16 }, [error]);
17
18 return (
19 <div className="error-container">
20 <h2>Something went wrong!</h2>
21 <p>{error.message || 'An unexpected error occurred.'}</p>
22 <button
23 onClick={() => reset()}
24 className="button"
25 >
26 Try again
27 </button>
28 </div>
29 );
30}We can also create an Error Boundary for specific sections:
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>Error loading analytics</h3>
8 <p>We could not load the analytics data: {error.message}</p>
9 <button onClick={reset}>Try again</button>
10 </div>
11 );
12}And then use it for specific sections:
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 {/* ... other sections ... */}
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}Proper implementation of data streaming can significantly improve application performance. Here are some additional optimization techniques:
1// app/layout.tsx
2export default function RootLayout({ children }) {
3 return (
4 <html lang="en">
5 <body>
6 {/* Setting minimum delay for Suspense */}
7 <script dangerouslySetInnerHTML={{ __html: `
8 window.SuspenseMinDelay = 300; // ms
9 ` }} />
10 {children}
11 </body>
12 </html>
13 );
14}We can also create a custom component for delaying the loading state:
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'; // Critical data
4import { ProductDetails } from './product-details'; // Important data
5import { ProductReviews } from './product-reviews'; // Less critical data
6import { RelatedProducts } from './related-products'; // Least critical data
7import { Loading } from '@/components/loading';
8
9export default async function ProductPage({ params }) {
10 // Pre-initialize critical data
11 preloadProductBasicInfo((await params).id);
12
13 return (
14 <div className="product-page">
15 {/* Immediate rendering of critical information */}
16 <div className="product-hero">
17 <Suspense fallback={<Loading priority="high" />}>
18 <ProductBasicInfo productId={(await params).id} />
19 </Suspense>
20 </div>
21
22 {/* Less critical sections loaded later */}
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// Pre-initialize critical data
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' // Setting the fetch API priority
52 });
53 return res.json();
54}1// components/loading.tsx
2export function Loading({ type, priority = 'medium', height, width }) {
3 // Determine skeleton dimensions based on content type
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 // Predefined dimensions prevent layout shifts
13 }}
14 >
15 <div className="skeleton-animation" />
16 </div>
17 );
18}
19
20function getDimensionsForType(type, userDimensions) {
21 // Default dimensions for different content types
22 const defaults = {
23 'header': { height: '100px', width: '100%' },
24 'products': { height: '300px', width: '100%' },
25 'sidebar': { height: '400px', width: '250px' },
26 // ... other types
27 };
28
29 return {
30 height: userDimensions.height || defaults[type]?.height || '200px',
31 width: userDimensions.width || defaults[type]?.width || '100%'
32 };
33}Data streaming is particularly useful in the following scenarios:
Data streaming and React Suspense in Next.js App Router offer powerful tools for creating more performant and responsive applications. The key advantages of this approach are:
When implementing data streaming in your Next.js applications, remember the following best practices:
In the next module, we will discuss in detail how to create advanced loading skeletons and progress indicators that complement the data streaming strategy, ensuring an even better user experience.