Skip to content
Codeloom
Next.js

Next.js Streaming and Loading UI: Instant Pages

Build instant-loading Next.js pages with streaming, Suspense boundaries, loading.tsx files, and skeleton UIs for the best user experience.

·9 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How streaming works in Next.js App Router
  • Using loading.tsx for automatic loading states
  • Creating Suspense boundaries for granular control
  • Building skeleton UIs for polished loading experiences

Prerequisites

  • Next.js App Router basics
  • Familiarity with React Server Components

What Is Streaming?

Traditional server rendering works like this: the server fetches all the data, renders the entire page, and sends the complete HTML to the browser. If any data fetch is slow, the entire page is slow.

Streaming changes this. The server sends the page shell immediately and then streams in each section as its data becomes available. The user sees content appearing progressively instead of staring at a blank screen.

Next.js App Router supports streaming out of the box. Every Server Component that is wrapped in a Suspense boundary can stream independently. The shell loads fast, and slow sections fill in as their data arrives.

The loading.tsx Convention

The simplest way to add a loading state is to create a loading.tsx file next to your page.tsx. Next.js automatically wraps your page in a Suspense boundary and shows the loading component while the page data is being fetched:

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="max-w-6xl mx-auto p-6">
      <div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-6" />
      <div className="grid md:grid-cols-3 gap-6">
        {[1, 2, 3].map((i) => (
          <div key={i} className="border rounded-lg p-6">
            <div className="h-4 w-24 bg-gray-200 rounded animate-pulse mb-3" />
            <div className="h-8 w-32 bg-gray-200 rounded animate-pulse" />
          </div>
        ))}
      </div>
    </div>
  );
}
// app/dashboard/page.tsx
async function getStats() {
  // This might take 2-3 seconds
  const res = await fetch("https://api.example.com/stats");
  return res.json();
}

export default async function DashboardPage() {
  const stats = await getStats();

  return (
    <div className="max-w-6xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">Dashboard</h1>
      <div className="grid md:grid-cols-3 gap-6">
        <StatCard title="Revenue" value={stats.revenue} />
        <StatCard title="Users" value={stats.users} />
        <StatCard title="Orders" value={stats.orders} />
      </div>
    </div>
  );
}

function StatCard({ title, value }: { title: string; value: string }) {
  return (
    <div className="border rounded-lg p-6">
      <p className="text-sm text-gray-500">{title}</p>
      <p className="text-2xl font-bold">{value}</p>
    </div>
  );
}

The user sees the skeleton loading UI immediately while the stats are being fetched from the API. Once the data arrives, the skeleton is replaced with the real content.

Granular Streaming with Suspense

loading.tsx wraps the entire page. For more control, use Suspense boundaries directly to stream individual sections independently:

// app/dashboard/page.tsx
import { Suspense } from "react";

export default function DashboardPage() {
  return (
    <div className="max-w-6xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">Dashboard</h1>

      {/* Stats load fast - shows first */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsSection />
      </Suspense>

      <div className="grid md:grid-cols-2 gap-6 mt-8">
        {/* These stream independently */}
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>

        <Suspense fallback={<TableSkeleton />}>
          <RecentOrders />
        </Suspense>
      </div>

      {/* Recommendations are slow - streams in last */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations />
      </Suspense>
    </div>
  );
}

Each section is a separate Server Component that fetches its own data:

// components/StatsSection.tsx
async function getStats() {
  const res = await fetch("https://api.example.com/stats", {
    next: { revalidate: 60 },
  });
  return res.json();
}

export async function StatsSection() {
  const stats = await getStats();

  return (
    <div className="grid md:grid-cols-4 gap-4">
      <StatCard title="Revenue" value={`$${stats.revenue.toLocaleString()}`} trend={stats.revenueTrend} />
      <StatCard title="Users" value={stats.users.toLocaleString()} trend={stats.usersTrend} />
      <StatCard title="Orders" value={stats.orders.toLocaleString()} trend={stats.ordersTrend} />
      <StatCard title="Conversion" value={`${stats.conversion}%`} trend={stats.conversionTrend} />
    </div>
  );
}

function StatCard({
  title,
  value,
  trend,
}: {
  title: string;
  value: string;
  trend: number;
}) {
  const isPositive = trend > 0;

  return (
    <div className="border rounded-lg p-4">
      <p className="text-sm text-gray-500">{title}</p>
      <p className="text-2xl font-bold mt-1">{value}</p>
      <p className={`text-sm mt-1 ${isPositive ? "text-green-600" : "text-red-600"}`}>
        {isPositive ? "+" : ""}{trend}%
      </p>
    </div>
  );
}
// components/RecentOrders.tsx
async function getRecentOrders() {
  // Simulating a slower API call
  const res = await fetch("https://api.example.com/orders/recent");
  return res.json();
}

export async function RecentOrders() {
  const orders = await getRecentOrders();

  return (
    <div className="border rounded-lg p-6">
      <h2 className="text-lg font-semibold mb-4">Recent Orders</h2>
      <table className="w-full">
        <thead>
          <tr className="text-left text-sm text-gray-500">
            <th className="pb-2">Order</th>
            <th className="pb-2">Customer</th>
            <th className="pb-2">Amount</th>
            <th className="pb-2">Status</th>
          </tr>
        </thead>
        <tbody>
          {orders.map((order: any) => (
            <tr key={order.id} className="border-t">
              <td className="py-2 text-sm">#{order.id}</td>
              <td className="py-2 text-sm">{order.customer}</td>
              <td className="py-2 text-sm">${order.amount}</td>
              <td className="py-2">
                <span className="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700">
                  {order.status}
                </span>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

The dashboard shell, heading, and layout appear instantly. The stats section streams in first because that API is fast. The chart and orders table stream in whenever their data arrives, independently of each other. Recommendations, being the slowest, appear last.

Building Skeleton Components

Good skeleton UIs match the shape of the actual content. This prevents layout shift and gives users a sense of what is coming:

// components/skeletons.tsx
export function StatsSkeleton() {
  return (
    <div className="grid md:grid-cols-4 gap-4">
      {[1, 2, 3, 4].map((i) => (
        <div key={i} className="border rounded-lg p-4">
          <div className="h-3 w-16 bg-gray-200 rounded animate-pulse" />
          <div className="h-7 w-24 bg-gray-200 rounded animate-pulse mt-2" />
          <div className="h-3 w-12 bg-gray-200 rounded animate-pulse mt-2" />
        </div>
      ))}
    </div>
  );
}

export function ChartSkeleton() {
  return (
    <div className="border rounded-lg p-6">
      <div className="h-5 w-32 bg-gray-200 rounded animate-pulse mb-4" />
      <div className="h-64 bg-gray-100 rounded animate-pulse" />
    </div>
  );
}

export function TableSkeleton() {
  return (
    <div className="border rounded-lg p-6">
      <div className="h-5 w-32 bg-gray-200 rounded animate-pulse mb-4" />
      <div className="space-y-3">
        {[1, 2, 3, 4, 5].map((i) => (
          <div key={i} className="flex gap-4">
            <div className="h-4 w-16 bg-gray-200 rounded animate-pulse" />
            <div className="h-4 w-24 bg-gray-200 rounded animate-pulse" />
            <div className="h-4 w-16 bg-gray-200 rounded animate-pulse" />
            <div className="h-4 w-20 bg-gray-200 rounded animate-pulse" />
          </div>
        ))}
      </div>
    </div>
  );
}

export function RecommendationsSkeleton() {
  return (
    <div className="mt-8">
      <div className="h-5 w-40 bg-gray-200 rounded animate-pulse mb-4" />
      <div className="grid md:grid-cols-3 gap-4">
        {[1, 2, 3].map((i) => (
          <div key={i} className="border rounded-lg p-4">
            <div className="h-32 bg-gray-100 rounded animate-pulse mb-3" />
            <div className="h-4 w-full bg-gray-200 rounded animate-pulse mb-2" />
            <div className="h-4 w-2/3 bg-gray-200 rounded animate-pulse" />
          </div>
        ))}
      </div>
    </div>
  );
}

A reusable skeleton primitive simplifies this pattern:

// components/Skeleton.tsx
type SkeletonProps = {
  className?: string;
  width?: string;
  height?: string;
};

export function Skeleton({ className = "", width, height }: SkeletonProps) {
  return (
    <div
      className={`bg-gray-200 rounded animate-pulse ${className}`}
      style={{ width, height }}
    />
  );
}

Nested Suspense Boundaries

Suspense boundaries can be nested. Inner boundaries resolve independently of outer ones:

// app/blog/[slug]/page.tsx
import { Suspense } from "react";

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  return (
    <article className="max-w-3xl mx-auto p-6">
      {/* Post content loads with the page */}
      <h1 className="text-3xl font-bold mb-4">{post.title}</h1>
      <div className="prose" dangerouslySetInnerHTML={{ __html: post.content }} />

      {/* Comments stream in separately */}
      <section className="mt-12">
        <h2 className="text-xl font-semibold mb-4">Comments</h2>
        <Suspense fallback={<CommentsSkeleton />}>
          <Comments postId={post.id} />
        </Suspense>
      </section>

      {/* Related posts stream in independently */}
      <section className="mt-12">
        <h2 className="text-xl font-semibold mb-4">Related Posts</h2>
        <Suspense fallback={<RelatedPostsSkeleton />}>
          <RelatedPosts tags={post.tags} currentSlug={slug} />
        </Suspense>
      </section>
    </article>
  );
}

The blog post content appears immediately because it was fetched at the page level. Comments and related posts stream in whenever their data resolves, without blocking each other or the main content.

Error Handling in Streamed Sections

When a streamed section fails, you want to show an error for that section without breaking the rest of the page. Combine Suspense with error boundaries:

// components/StreamedSection.tsx
"use client";

import { Component, type ReactNode, Suspense } from "react";

type ErrorBoundaryProps = {
  fallback: ReactNode;
  children: ReactNode;
};

type ErrorBoundaryState = {
  hasError: boolean;
};

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(): ErrorBoundaryState {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

export function StreamedSection({
  children,
  loadingFallback,
  errorFallback,
}: {
  children: ReactNode;
  loadingFallback: ReactNode;
  errorFallback?: ReactNode;
}) {
  const errorUI = errorFallback || (
    <div className="border border-red-200 rounded-lg p-4 bg-red-50">
      <p className="text-red-700 text-sm">Failed to load this section.</p>
    </div>
  );

  return (
    <ErrorBoundary fallback={errorUI}>
      <Suspense fallback={loadingFallback}>{children}</Suspense>
    </ErrorBoundary>
  );
}

Use it in your pages:

import { StreamedSection } from "@/components/StreamedSection";

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <StreamedSection loadingFallback={<StatsSkeleton />}>
        <StatsSection />
      </StreamedSection>

      <StreamedSection
        loadingFallback={<ChartSkeleton />}
        errorFallback={
          <div className="p-4 border rounded-lg text-center text-gray-500">
            Chart data is temporarily unavailable.
          </div>
        }
      >
        <RevenueChart />
      </StreamedSection>
    </div>
  );
}

If the revenue chart API fails, the user sees a graceful error message for that section while the rest of the dashboard works normally.

Streaming with Dynamic Imports

For Client Components that are heavy, combine streaming with dynamic imports to defer their loading:

import dynamic from "next/dynamic";
import { Suspense } from "react";

const HeavyChart = dynamic(() => import("@/components/HeavyChart"), {
  ssr: false,
});

export default async function AnalyticsPage() {
  const data = await getAnalyticsData();

  return (
    <div>
      <h1>Analytics</h1>

      {/* Server-rendered data appears immediately */}
      <div className="grid grid-cols-3 gap-4 mb-8">
        {data.metrics.map((metric: any) => (
          <div key={metric.name} className="border rounded p-4">
            <p className="text-sm text-gray-500">{metric.name}</p>
            <p className="text-xl font-bold">{metric.value}</p>
          </div>
        ))}
      </div>

      {/* Heavy chart component loads on the client */}
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart data={data.chartData} />
      </Suspense>
    </div>
  );
}

When to Use Each Pattern

loading.tsx is the right choice when an entire page needs a loading state. It is the simplest option and works well for pages where all the data loads together.

Suspense boundaries are better when different sections of a page have different data sources with varying speeds. Wrap the slow sections and let the fast ones render immediately.

Nested Suspense makes sense for complex pages with multiple independent data requirements. Each boundary resolves on its own timeline.

Dynamic imports with Suspense work best for heavy Client Components like charts, maps, or rich text editors that you do not want in the initial bundle.

The general rule: put Suspense boundaries around the slowest parts of your page. Everything outside those boundaries renders instantly, giving users immediate visual feedback while slow data streams in.

Wrapping Up

Streaming in Next.js transforms the loading experience from “wait for everything” to “see content progressively.” The loading.tsx convention handles the common case with zero configuration. Suspense boundaries give you fine-grained control over which sections stream independently. And skeleton UIs that match your content layout prevent jarring layout shifts.

Start by adding a loading.tsx file to your slowest pages. Then identify sections that can stream independently and wrap them in Suspense boundaries with matching skeleton components. The result is an application that feels instant, even when the underlying data takes seconds to fetch.