DEV Community

Cover image for ⚡ Streaming and Caching for a Better UX in Next.js
Fredrick Ogutu
Fredrick Ogutu

Posted on

⚡ Streaming and Caching for a Better UX in Next.js

When building data-driven applications in Next.js, one of the most common bottlenecks you’ll face is slow data fetching. Whether you’re pulling data from a remote API or querying your own backend, a delay in just one request can hold back the entire page from rendering.

As the saying goes,

“Your application is only as fast as its slowest data fetch.”

This is especially true for dynamically rendered pages.

In one of my recent projects, I noticed a significant delay when fetching blog posts from the Dev.to API. While the data itself wasn’t heavy, the delay between fetching and rendering made the page feel sluggish. This experience led me to explore two powerful techniques built into Next.js streaming and caching to make my app feel fast, responsive, and smooth.

🌊 Streaming

Streaming allows you to send parts of a page to the client as soon as they’re ready, instead of waiting for all data to load. This means users can start interacting with sections of the UI immediately, even while slower parts are still rendering in the background.

It’s like serving snacks while the main course is still cooking, your users can start interacting with something right away, reducing perceived load time.

Next.js supports streaming at two levels:

  • Page-level streaming using loading.tsx files.
  • Component-level streaming using React’s <Suspense>.

Streaming a Whole Page

At the page level, you can use a loading.tsx file to display fallback UI while your data is being fetched. This helps avoid the “white screen” problem, where nothing is visible to users while they wait.

// app/blog/loading.tsx
import React from "react";

export default function Loading() {
  return (
    <main className="min-h-screen px-4 py-6">
      {/* Header skeleton */}
      <header className="mx-auto max-w-5xl mb-8">
        <div className="flex items-center gap-4">
          {/* avatar */}
          <div
            className="h-10 w-10 rounded-full bg-md-background animate-pulse"
            aria-hidden
          />
          {/* site title placeholder */}
          <div className="flex-1">
            <div className="h-6 w-64 rounded-md bg-md-background animate-pulse" />
            <div className="mt-3 flex gap-3">
              {/* tag placeholders */}
              <div className="h-6 w-20 rounded-full bg-md-background animate-pulse" />
              <div className="h-6 w-24 rounded-full bg-md-background animate-pulse" />
              <div className="h-6 w-16 rounded-full bg-md-background animate-pulse" />
            </div>
          </div>
        </div>
      </header>

      {/* Cards grid skeleton */}
      <section className="mx-auto max-w-5xl">
        <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
          {Array.from({ length: 2 }).map((_, i) => (
            <article
              key={i}
              className="rounded-2xl bg-white shadow-[0_10px_35px_rgba(99,102,241,0.12)] p-6"
              aria-hidden
            >
              {/* image */}
              <div className="h-40 md:h-48 w-full rounded-lg bg-md-background animate-pulse mb-4" />

              {/* title + date row */}
              <div className="mb-3">
                <div className="h-5 w-3/4 rounded-md bg-md-background animate-pulse mb-2" />
                <div className="h-3 w-1/4 rounded-md bg-md-background animate-pulse" />
              </div>

              {/* excerpt */}
              <div className="space-y-2 mb-4">
                <div className="h-3 w-full rounded-md bg-md-background animate-pulse" />
                <div className="h-3 w-11/12 rounded-md bg-md-background animate-pulse" />
                <div className="h-3 w-8/12 rounded-md bg-md-background animate-pulse" />
              </div>

              {/* tags */}
              <div className="flex flex-wrap gap-2 mb-6">
                <div className="h-8 w-20 rounded-full bg-md-background animate-pulse" />
                <div className="h-8 w-24 rounded-full bg-md-background animate-pulse" />
                <div className="h-8 w-16 rounded-full bg-md-background animate-pulse" />
              </div>

              {/* read more button */}
              <div className="w-36 h-10 rounded-full bg-md-background animate-pulse" />
            </article>
          ))}
        </div>
      </section>
    </main>
  );

Enter fullscreen mode Exit fullscreen mode

With this file in place, Next.js automatically serves it as a temporary placeholder whenever the /blog route is loading. Users can still navigate around without waiting for the full page to render.

This design mimics my actual blog cards image at the top, followed by the title, date, tags, and a “Read more” button.
Even though the data hasn’t loaded yet, users can clearly see what to expect.

Streaming at the Component Level

While page-level streaming is useful, sometimes you want even finer control. That’s where React’s suspense component comes in.

It lets you defer rendering specific components until their data is ready, ideal for dynamic sections of your app.

Here’s how I wrapped my Blogs component:

// app/blog/page.tsx
import { Suspense } from 'react';
import Blogs from '../ui/blog/Blogs';
import { BlogSkeleton } from '../ui/blog/skeletons';

export default async function Blog() {
  return (
    <div className="relative pt-2 px-2">
      <Suspense fallback={<BlogSkeleton />}>
        <Blogs />
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

// app/ui/blog/skeletons.tsx
export function BlogSkeleton() {
  return (
    <section className="h-full w-full bg-inherit flex flex-col justify-center items-center gap-6 md:flex-row md:gap-12">
      {[...Array(3)].map((_, i) => (
        <div key={i} className="flex flex-col items-center w-11/12 px-2 py-3">
          <div className="mt-3 w-11/12 h-2/6 bg-md-background"></div>
          <div className="mt-3 w-full bg-md-background"></div>
          <div className="bg-md-background"></div>
          <div className="w-4/6 mx-auto"></div>
        </div>
      ))}
    </section>
  );
}

Enter fullscreen mode Exit fullscreen mode

With this setup, the static parts of the page render immediately, while the <Blogs /> component streams in once its data has finished loading. If the fetch takes longer, users see the BlogSkeleton instead of a blank space a small detail that greatly improves perceived responsiveness.

Caching

Streaming improves how data loads. Caching improves how often data needs to load.

Caching stores the results of expensive operations (like API requests) so that subsequent requests for the same data are served faster. This is especially powerful in server components, where you can use React’s built-in cache() function.

Here’s how I applied caching to my blog data fetch:

// app/lib/fetchBlogs.ts
import { cache } from "react";
import { Blog } from "../types/index";

export const fetchBlogs = cache(async function fetchBlogs(): Promise<{ blogs: Blog[]; count: number }> {
  try {
    const res = await fetch("https://dev.to/api/articles?username=fiveace_merill");
    const blogs: Blog[] = await res.json();

    const simplifiedBlogs = blogs.map(({ id, title, description, published_at, url, cover_image, tag_list }) => ({
      id,
      title,
      description,
      published_at,
      url,
      cover_image,
      tag_list,
    }));

    const count = simplifiedBlogs.length;
    console.log("Articles fetched successfully");
    console.log(`Total blogs: ${count}`);

    return { blogs: simplifiedBlogs, count };
  } catch (error) {
    console.error(`Error fetching articles: ${error}`);
    return { blogs: [], count: 0 };
  }
});
Enter fullscreen mode Exit fullscreen mode

By wrapping the fetch logic inside cache(), repeated requests for the same data are served instantly from memory rather than refetching from the API. This significantly reduces latency and helps avoid hitting external API rate limits.

Enabling Cache Components

Next.js also lets you enable caching at the component level using Cache Components a newer optimization that preserves state during navigation.

To enable it, update your Next.js config:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

Once enabled, Next.js uses React’s Activity mode to keep component state intact during client-side navigation.

This means:

  • Form inputs and expanded sections remain preserved when moving between routes.
  • Navigation feels faster and more fluid.
  • Effects are cleaned up and restored only when needed.

In short, caching complements streaming by making transitions and repeated renders smoother without unnecessary data fetching.

To cache a component add use chache to any server component to make it cached and included in the prerendered shell or mark utility functions as use cache and call them from within the server component.

export async function fetchProjects() {
  'use cache'
  try {
    const data = await sql`SELECT * FROM projects LIMIT 3`;
    console.log('Data fetch completed after seconds.');
    return data;
  } catch (error) {
    console.error('Database Error:', error);
    throw new Error('Failed to fetch projects data.')
  }
}
Enter fullscreen mode Exit fullscreen mode

You can not use runtime APIs like cookies and headers from inside a cached component.

Conclusion

Optimizing user experience isn’t just about making things faster it’s about making them feel faster.

By combining Streaming and Caching in Next.js, I was able to transform a slow, blocking data fetch into an experience where content appears progressively, interactions remain fluid, and returning users benefit from cached data.

It’s these small technical adjustments that often have the biggest impact on how users perceive your app. Next time you’re faced with sluggish data fetching, consider streaming your components and caching your results your users will thank you.

Top comments (0)