~/TechPurAI
~/tutorials/nextjs-from-scratch/loading-ui-and-suspense
intermediate·part 7 of 22·2 min read

Loading UI with loading.tsx and Suspense

Updated Aug 16, 2026JavaScript · Next.js

An await inside a Server Component blocks that page from rendering anything at all until it resolves — with the in-memory data from part 5 that's instant, but a real database call or slow external API leaves a visitor looking at a blank browser tab in the meantime. This part fixes that.

loading.tsx: automatic, route-level loading UI

tsx
// app/posts/loading.tsx
export default function PostsLoading() {
  return <p>Loading posts…</p>;
}

Adding this one file is the entire integration — no import, no manual wiring. Next.js automatically wraps app/posts/page.tsx in a Suspense boundary using PostsLoading as the fallback, so it shows immediately while PostsPage's data fetch is still in flight, then swaps to the real content the moment it resolves. The same file also covers every dynamic route beneath it, including [slug], unless that segment defines its own more specific loading.tsx.

When a whole-page loading state isn't precise enough

A loading.tsx swaps the entire page for a fallback — fine for a first visit, less ideal when only one slow piece of an otherwise-ready page is holding everything back. Suspense, used directly, scopes that to just the slow part.

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

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = getAllPosts().find((p) => p.slug === slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <Suspense fallback={<p>Loading comments…</p>}>
        <Comments slug={slug} />
      </Suspense>
    </article>
  );
}

async function Comments({ slug }: { slug: string }) {
  const comments = await getCommentsForPost(slug);
  return (
    <ul>
      {comments.map((c) => <li key={c.id}>{c.body}</li>)}
    </ul>
  );
}

The post's title and content — fast, already-available data — render immediately. Comments, a separate async component with its own (potentially slower) fetch, streams in independently inside its own Suspense boundary, without holding up the rest of the page that was already ready.

Why this matters for a real page

A page with several independent data sources — post content, comments, related posts, view count — doesn't have to wait on the slowest one before showing anything. Each piece gets its own Suspense boundary, and Next.js streams each one in as it resolves, in whatever order they actually finish, rather than the whole page being gated on all of them together.

Common mistake

Wrapping the entire page's content in one Suspense boundary "to be safe," which produces exactly the same all-or-nothing behavior as not using Suspense at all. The benefit only shows up when independent pieces get independent boundaries — a single boundary around everything just adds an extra layer with no actual streaming benefit.

Next: what happens when a fetch fails, or a route genuinely doesn't exist — error boundaries and the not-found.tsx used in part 6.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous6. Dynamic routes and generateStaticParamsnext →8. Error handling: error.tsx and not-found.tsx