Next.js Loading UI and Suspense

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Next.js Loading UI and Suspense

React Suspense and Next.js loading conventions provide fine-grained control over which parts of your page show loading states while data loads.

Creating Loading UI

Add loading.tsx to show a spinner while the page loads:

// app/blog/loading.tsx
export default function BlogLoading() {
  return (
    <div className="space-y-4">
      <div className="h-8 bg-gray-200 rounded animate-pulse" />
      <div className="h-8 bg-gray-200 rounded animate-pulse" />
      <div className="h-8 bg-gray-200 rounded animate-pulse" />
    </div>
  )
}

When users navigate to /blog, they see this skeleton while the real page loads.

Suspense Boundaries

Use Suspense for granular loading control:

// app/blog/page.tsx
import { Suspense } from 'react'
import PostList from './post-list'
import PostListSkeleton from './post-list-skeleton'

export default function BlogPage() {
  return (
    <div>
      <h1>Blog</h1>
      <Suspense fallback={<PostListSkeleton />}>
        <PostList />
      </Suspense>
    </div>
  )
}

PostList can be async and fetch data. While loading, PostListSkeleton shows.

Async Server Components

Create async Server Components that work with Suspense:

// app/blog/post-list.tsx
async function getPosts() {
  await new Promise(resolve => setTimeout(resolve, 2000)) // Simulate loading
  const posts = await db.posts.findMany()
  return posts
}

export default async function PostList() {
  const posts = await getPosts()

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}
// app/blog/post-list-skeleton.tsx
export default function PostListSkeleton() {
  return (
    <ul className="space-y-4">
      {[1, 2, 3].map(i => (
        <li key={i} className="h-12 bg-gray-200 rounded animate-pulse" />
      ))}
    </ul>
  )
}

Multiple Suspense Boundaries

Show different loading states for different parts:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import UserCard from './user-card'
import AnalyticsChart from './analytics-chart'
import Skeleton from './skeleton'

export default function DashboardPage() {
  return (
    <div className="grid gap-4">
      <Suspense fallback={<Skeleton />}>
        <UserCard />
      </Suspense>

      <Suspense fallback={<Skeleton />}>
        <AnalyticsChart />
      </Suspense>
    </div>
  )
}

Users see each section's skeleton independently as it loads.

Real-World: Blog with Comments

// app/blog/[slug]/page.tsx
import { Suspense } from 'react'
import PostContent from './post-content'
import CommentSection from './comments'
import CommentSkeleton from './comment-skeleton'

export default async function BlogPostPage({ params }) {
  return (
    <article>
      <PostContent slug={params.slug} />

      <hr />

      <Suspense fallback={<CommentSkeleton />}>
        <CommentSection slug={params.slug} />
      </Suspense>
    </article>
  )
}
// app/blog/[slug]/post-content.tsx
async function getPost(slug: string) {
  const post = await db.posts.findUnique({ where: { slug } })
  return post
}

export default async function PostContent({ slug }: { slug: string }) {
  const post = await getPost(slug)

  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </div>
  )
}
// app/blog/[slug]/comments.tsx
async function getComments(slug: string) {
  await new Promise(r => setTimeout(r, 2000)) // Simulate slow API
  const comments = await db.comments.findMany({ where: { postSlug: slug } })
  return comments
}

export default async function CommentSection({ slug }: { slug: string }) {
  const comments = await getComments(slug)

  return (
    <div>
      <h2>Comments ({comments.length})</h2>
      <ul>
        {comments.map(comment => (
          <li key={comment.id}>
            <p className="font-bold">{comment.author}</p>
            <p>{comment.text}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}

Error Boundaries with Suspense

Combine error boundaries with Suspense:

// app/products/page.tsx
import { Suspense } from 'react'
import ProductList from './product-list'
import { ErrorBoundary } from '@/components/error-boundary'

export default function ProductsPage() {
  return (
    <ErrorBoundary fallback={<ProductListError />}>
      <Suspense fallback={<ProductListSkeleton />}>
        <ProductList />
      </Suspense>
    </ErrorBoundary>
  )
}

If an error occurs, ProductListError shows. While loading, ProductListSkeleton shows.

Server Component Patterns with Suspense

Pattern 1: Sequential Loading

export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<UserSkeleton />}>
        <UserInfo />
      </Suspense>

      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>
    </div>
  )
}

Each section loads independently.

Pattern 2: Parallel Loading

export default async function Dashboard() {
  // Fetch in parallel
  const [user, stats] = await Promise.all([
    fetchUser(),
    fetchStats()
  ])

  return (
    <div>
      <UserCard user={user} />
      <StatsCard stats={stats} />
    </div>
  )
}

All data loads simultaneously.

Custom Skeletons

Create reusable skeleton components:

// components/skeletons.tsx
export function PostSkeleton() {
  return (
    <div className="space-y-4">
      <div className="h-8 bg-gray-200 rounded w-3/4 animate-pulse" />
      <div className="h-4 bg-gray-200 rounded w-full animate-pulse" />
      <div className="h-4 bg-gray-200 rounded w-5/6 animate-pulse" />
    </div>
  )
}

export function CardSkeleton() {
  return (
    <div className="bg-white p-4 rounded-lg shadow">
      <div className="h-40 bg-gray-200 rounded animate-pulse" />
      <div className="h-4 bg-gray-200 rounded mt-4 animate-pulse" />
    </div>
  )
}

Nested Suspense

Create hierarchies of loading states:

export default function Page() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <MainContent />
    </Suspense>
  )
}

async function MainContent() {
  return (
    <div>
      <Suspense fallback={<SectionSkeleton />}>
        <Section1 />
      </Suspense>

      <Suspense fallback={<SectionSkeleton />}>
        <Section2 />
      </Suspense>
    </div>
  )
}

If Section1 is slower than Section2, users see SectionSkeleton only for Section1.

FAQ

Q: What's the difference between loading.tsx and Suspense? A: loading.tsx shows for entire route segments. Suspense provides fine-grained control for specific components.

Q: Can I use Suspense in Client Components? A: Yes, but the Server Component inside must be lazy-loaded. Suspense works best with async Server Components.

Q: Should I show a skeleton or a generic loader? A: Content-aware skeletons (showing the shape of content coming) are better UX than generic spinners.


Master Suspense and loading states for polished user experiences.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro