Skip to main content

Next.js Streaming

Introduction

Streaming is a powerful feature in Next.js that allows you to progressively render UI components as their data becomes available. Instead of waiting for all data fetching to complete before showing anything to users, streaming enables your application to display parts of the page as soon as they're ready, improving perceived performance and user experience.

This approach is particularly valuable when dealing with slower data fetches that might otherwise block the entire page from rendering. With streaming, users see content sooner and can start interacting with parts of the page while other sections are still loading.

In this guide, we'll explore how streaming works in Next.js, when to use it, and how to implement it in your applications.

Understanding Streaming in Next.js

What is Streaming?

Streaming in Next.js is built on React's Server Components and Suspense features. It allows the server to send UI pieces to the client incrementally as they become ready, rather than waiting for the entire page to be rendered server-side.

Here's how streaming works at a high level:

  1. The server begins rendering the page
  2. It sends the initial HTML immediately
  3. When components wrapped in Suspense boundaries are encountered with pending data, they're replaced with fallback content
  4. As data becomes available, the server streams the UI components to the client
  5. The client seamlessly updates the page with the new content

Key Benefits of Streaming

  • Improved perceived performance: Users see content faster
  • Reduced Time to First Byte (TTFB): Initial HTML is sent quickly
  • Progressive rendering: Critical UI elements appear first
  • Enhanced user experience: Pages feel more responsive
  • Reduced blocking: Slow data fetches don't block the entire page

Implementing Streaming in Next.js

Basic Streaming with Suspense

The simplest way to implement streaming is by using React's Suspense component to wrap parts of your UI that depend on data fetching.

jsx
// app/dashboard/page.js
import { Suspense } from 'react';
import Loading from './loading';
import DashboardContent from './dashboard-content';
import RecentActivity from './recent-activity';
import PopularPosts from './popular-posts';

export default function DashboardPage() {
return (
<div className="dashboard">
<h1>Dashboard</h1>

{/* This content loads immediately */}
<DashboardContent />

{/* These components will stream in as their data becomes available */}
<div className="dashboard-widgets">
<Suspense fallback={<Loading message="Loading recent activity..." />}>
<RecentActivity />
</Suspense>

<Suspense fallback={<Loading message="Loading popular posts..." />}>
<PopularPosts />
</Suspense>
</div>
</div>
);
}

In this example, the DashboardContent component renders immediately, while RecentActivity and PopularPosts show loading states until their data is available.

Creating Loading Components

For a good streaming experience, you'll want to create meaningful loading states:

jsx
// app/dashboard/loading.js
export default function Loading({ message = 'Loading...' }) {
return (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>{message}</p>
</div>
);
}

Data Fetching with Streaming

When using streaming, you'll typically use asynchronous functions to fetch data within your Server Components:

jsx
// app/dashboard/recent-activity.js
async function getRecentActivity() {
// Simulate a slow API call
await new Promise(resolve => setTimeout(resolve, 2000));

return [
{ id: 1, action: 'Logged in', time: '2 minutes ago' },
{ id: 2, action: 'Updated profile', time: '1 hour ago' },
{ id: 3, action: 'Posted new comment', time: '3 hours ago' }
];
}

export default async function RecentActivity() {
// This component will suspend until the data is available
const activities = await getRecentActivity();

return (
<div className="recent-activity">
<h2>Recent Activity</h2>
<ul>
{activities.map(activity => (
<li key={activity.id}>
<span>{activity.action}</span>
<small>{activity.time}</small>
</li>
))}
</ul>
</div>
);
}

Advanced Streaming Techniques

Nested Suspense Boundaries

You can create more granular streaming experiences by nesting Suspense boundaries:

jsx
// app/product/[id]/page.js
import { Suspense } from 'react';
import ProductHeader from './product-header';
import ProductDetails from './product-details';
import RelatedProducts from './related-products';
import ReviewSection from './review-section';
import Loading from './loading';

export default function ProductPage({ params }) {
return (
<div className="product-page">
<Suspense fallback={<Loading message="Loading product..." />}>
<ProductHeader id={params.id} />
<ProductDetails id={params.id} />

<Suspense fallback={<Loading message="Loading related items..." />}>
<RelatedProducts id={params.id} />
</Suspense>

<Suspense fallback={<Loading message="Loading reviews..." />}>
<ReviewSection id={params.id} />
</Suspense>
</Suspense>
</div>
);
}

With this setup, the product information will stream in first, followed by related products and reviews as they become available.

Using loading.js with Streaming

Next.js provides a special loading.js file that creates an automatic Suspense boundary for an entire page or layout:

jsx
// app/dashboard/loading.js
export default function DashboardLoading() {
return (
<div className="dashboard-loading">
<h1>Dashboard</h1>
<div className="loading-skeleton">
<div className="skeleton-header"></div>
<div className="skeleton-content"></div>
<div className="skeleton-widgets"></div>
</div>
</div>
);
}

This file will automatically wrap the page in a Suspense boundary and show the loading UI while the page is streaming.

Parallel Data Fetching

To optimize streaming, you can initiate multiple data fetches in parallel:

jsx
// app/analytics/page.js
import { Suspense } from 'react';
import VisitorStats from './visitor-stats';
import PageViews from './page-views';
import ConversionRates from './conversion-rates';
import Loading from './loading';

export default async function AnalyticsPage() {
// Start data fetches in parallel
const visitorPromise = getVisitorStats();
const pageViewPromise = getPageViews();
const conversionPromise = getConversionRates();

return (
<div className="analytics">
<h1>Analytics Dashboard</h1>

<div className="stats-grid">
<Suspense fallback={<Loading message="Loading visitor stats..." />}>
{/* Component will render when data is available */}
<VisitorStats promise={visitorPromise} />
</Suspense>

<Suspense fallback={<Loading message="Loading page views..." />}>
<PageViews promise={pageViewPromise} />
</Suspense>

<Suspense fallback={<Loading message="Loading conversion rates..." />}>
<ConversionRates promise={conversionPromise} />
</Suspense>
</div>
</div>
);
}

Real-World Example: E-commerce Product Page

Let's build a comprehensive example of a product page that uses streaming to provide a better user experience:

jsx
// app/products/[id]/page.js
import { Suspense } from 'react';
import ProductBasicInfo from './product-basic-info';
import ProductGallery from './product-gallery';
import ProductDetails from './product-details';
import ProductReviews from './product-reviews';
import RelatedProducts from './related-products';
import LoadingBasicInfo from './loading-basic-info';
import LoadingGallery from './loading-gallery';
import LoadingDetails from './loading-details';
import LoadingReviews from './loading-reviews';
import LoadingRelated from './loading-related';

export default function ProductPage({ params }) {
const { id } = params;

return (
<div className="product-page">
<div className="product-top-section">
{/* Critical product information loads first */}
<Suspense fallback={<LoadingBasicInfo />}>
<ProductBasicInfo id={id} />
</Suspense>

{/* Product gallery loads independently */}
<Suspense fallback={<LoadingGallery />}>
<ProductGallery id={id} />
</Suspense>
</div>

<div className="product-lower-section">
{/* Detailed specifications can take longer to load */}
<Suspense fallback={<LoadingDetails />}>
<ProductDetails id={id} />
</Suspense>

{/* Reviews can be very data-heavy */}
<Suspense fallback={<LoadingReviews />}>
<ProductReviews id={id} />
</Suspense>
</div>

{/* Related products section loads last */}
<Suspense fallback={<LoadingRelated />}>
<RelatedProducts id={id} />
</Suspense>
</div>
);
}

And here's how we might implement one of these components:

jsx
// app/products/[id]/product-reviews.js
async function getProductReviews(id) {
// Simulate a slow API call to get reviews
await new Promise(resolve => setTimeout(resolve, 3500));

return [
{ id: 1, user: 'Alex', rating: 5, comment: 'Love this product!' },
{ id: 2, user: 'Morgan', rating: 4, comment: 'Great quality but a bit expensive.' },
{ id: 3, user: 'Jordan', rating: 5, comment: 'Exactly what I needed.' },
// More reviews...
];
}

export default async function ProductReviews({ id }) {
const reviews = await getProductReviews(id);

return (
<section className="product-reviews">
<h2>Customer Reviews</h2>

<div className="average-rating">
<p>Average: {reviews.reduce((acc, r) => acc + r.rating, 0) / reviews.length} / 5</p>
</div>

<div className="review-list">
{reviews.map(review => (
<div key={review.id} className="review-item">
<div className="review-header">
<span className="user">{review.user}</span>
<span className="rating">{review.rating} stars</span>
</div>
<p className="comment">{review.comment}</p>
</div>
))}
</div>
</section>
);
}

Best Practices for Streaming

  1. Prioritize critical content: Place the most important content outside of Suspense boundaries so it loads first.

  2. Design meaningful loading states: Create loading placeholders that match the final content size to reduce layout shifts.

  3. Use skeleton screens: Instead of simple spinners, use skeleton UIs that resemble the final content.

  4. Choose sensible Suspense boundaries: Don't make your boundaries too fine-grained or too coarse.

  5. Combine with other performance techniques: Use streaming alongside image optimization, code splitting, and caching.

  6. Test with network throttling: Use browser devtools to simulate slower connections and see how your streaming experience works.

  7. Monitor user metrics: Track Core Web Vitals to ensure streaming is improving your actual user experience.

Common Streaming Patterns

Streaming Layouts

You can apply streaming to layouts as well as pages:

jsx
// app/dashboard/layout.js
import { Suspense } from 'react';
import Sidebar from './sidebar';
import TopNav from './top-nav';
import Loading from './loading';

export default function DashboardLayout({ children }) {
return (
<div className="dashboard-layout">
<TopNav />
<div className="content-with-sidebar">
<Suspense fallback={<Loading message="Loading sidebar..." />}>
<Sidebar />
</Suspense>

<main className="main-content">
{children}
</main>
</div>
</div>
);
}

Loading Data Above Components

Sometimes you want to fetch data at a higher level but stream the components:

jsx
// app/blog/category/[slug]/page.js
import { Suspense } from 'react';
import PostGrid from '../components/post-grid';
import CategoryInfo from '../components/category-info';
import Loading from '../components/loading';

export default async function CategoryPage({ params }) {
// Fetch category data at the page level
const categoryData = await getCategoryData(params.slug);

return (
<div className="category-page">
<h1>{categoryData.name}</h1>
<p>{categoryData.description}</p>

<Suspense fallback={<Loading message="Loading posts..." />}>
{/* Pass the category ID to the component that will fetch posts */}
<PostGrid categoryId={categoryData.id} />
</Suspense>
</div>
);
}

Debugging Streaming Issues

If you encounter issues with streaming, check for:

  1. Missing Suspense boundaries: Ensure components that fetch data are wrapped in Suspense.

  2. Client Components using Server Components: Client Components cannot directly render async Server Components.

  3. Data fetching in Client Components: Move data fetching to Server Components when possible.

  4. Too many Suspense boundaries: Too fine-grained boundaries can cause visual clutter with many loading states.

  5. Network issues: Test your application with network throttling to ensure streaming works under realistic conditions.

Summary

Next.js Streaming is a powerful feature that enables progressive rendering of UI components as their data becomes available. By using Suspense boundaries strategically, you can significantly improve perceived performance and user experience in your applications.

Key takeaways:

  • Streaming allows parts of your page to load progressively
  • Use Suspense to wrap components that depend on asynchronous data
  • Create meaningful loading states with skeleton UIs
  • Structure your components to prioritize critical content
  • Combine streaming with other performance optimization techniques

Next.js Streaming represents a shift in how we think about rendering web applications, focusing on getting content to users as soon as possible rather than waiting for everything to be ready.

Additional Resources

Practice Exercises

  1. Convert an existing Next.js page to use streaming for data-dependent components.

  2. Create a news article page that streams in the article content, comments, and related stories.

  3. Build a dashboard with multiple widgets that stream in as their data becomes available.

  4. Implement skeleton loading states that match the final content layouts.

  5. Compare the performance of a streamed page versus a non-streamed page using Lighthouse or Web Vitals.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)