Next.js App Router Tailwind Spinner Loading Page: Spinners vs Skeletons Explained

Written By Ajay Patel 15 mins
Next.js App Router Tailwind Spinner Loading Page: Spinners vs Skeletons Explained

Ever clicked a button and wondered if anything actually happened? We’ve all been there, staring at a blank screen, wondering if our internet died or if the app is just slow. As developers, we know the app is working hard in the background, but our users? They’re left in the dark, getting frustrated.

Loading states are one of those “invisible” features that can make or break your app’s user experience. When done right, they’re barely noticed. When done wrong (or not at all), they’re the reason users bounce from your site.

Today, we’re going to show you how to build a demo project that explores three different loading patterns in Next.js 16 using the App Router. We’ll use real API calls, implement spinners and skeleton screens with Tailwind CSS, and dive into React Suspense-all while maintaining clean, production-ready code.

By the end of this guide on Next.js App Router Tailwind Spinner Loading Page, you’ll know exactly when to use spinners versus skeletons, how to implement server-side and client-side loading patterns, and how to make your app feel incredibly responsive.

Let’s dive in.

Why Loading States Matter More Than You Think

Here’s something I learned the hard way: users don’t care about your fancy features if your app feels broken during loading.

Think about it-when you’re waiting for something to load, every second feels like an eternity. Research shows that:

  • 53% of mobile users abandon sites that take longer than 3 seconds to load
  • A 1-second delay can reduce conversions by 7%
  • Users perceive apps with loading indicators as 20% faster than those without

But here’s the thing: adding a spinner isn’t just about showing that something is happening. It’s about choosing the right loading pattern for the right situation.

That’s what we’re going to explore.

What We’re Building: Next.js App Router Tailwind Spinner Loading Page

Next.js App Router Tailwind Spinner Loading Page

I created a demo project that implements three different loading patterns you’ll encounter in real-world applications:

  1. Server Component with Streaming (Dashboard page) – Data fetches on the server and streams to the browser progressively
  2. Client Component with use() Hook (Profile page) – Modern React 19 pattern for client-side data fetching with Suspense
  3. Client Component with useState (Products page) – Traditional pattern with full manual control over loading states

Each pattern has its place, and I’ll show you exactly when to use which one. The key difference is in where the data is fetched (server vs browser) and how you handle loading states.

You can explore the complete code on GitHub: nextjs-spinner-blog-example

The Foundation: Next.js + Tailwind

For this project, I’m using:

  • Next.js v16 – The latest App Router with native Suspense support
  • React v19 – Includes the new use() hook for client-side data fetching with Suspense
  • Tailwind CSS v4 – No dependencies, just utility classes for spinners
  • TypeScript – Because runtime errors are no fun

The project structure is straightforward:

app/
├── dashboard/          # Server Component with Streaming
├── profile/            # Client Component with use() Hook
├── products/           # Client Component with useState
├── layout.tsx          # Root layout with navigation
└── components/
    ├── TableSkeleton.tsx   # Table skeleton loader
    └── CardSkeleton.tsx    # Card skeleton loader

Now let’s build something real.

Pattern 1: Building a Spinner with Tailwind CSS

First things first, we need a spinner. Now, you could pull in a library, but honestly? Tailwind CSS makes it trivially easy to build one yourself.

Here’s the ring spinner I created for this project:

// components/RingSpinner.tsx
const RingSpinner = () => {
  return (
    <div className="flex items-center justify-center">
      <div className="h-12 w-12 animate-spin rounded-full border-4 border-blue-200 border-t-blue-600 border-r-blue-600"></div>
    </div>
  );
}
export default RingSpinner;

The best part? It’s 5 lines of code, no dependencies, and it looks clean. You can customize the colors to match your brand in seconds.

Pattern 2: Server Component with Streaming (The Dashboard)

Let’s start with the most elegant pattern: Server Component with Streaming SSR. This is my favorite because of its incredible performance.

Here’s what makes it special: the server sends HTML in chunks. Static content (header, layout) arrives immediately, while dynamic data streams in when ready. This is true server-side rendering with streaming, not client-side fetching.

The Table Skeleton Component

First, I created a table skeleton component:

// components/TableSkeleton.tsx
export default function TableSkeleton() {
  return (
    <div className="w-full overflow-hidden rounded-lg border border-gray-200">
      <table className="w-full">
        <thead className="bg-gray-50">
          <tr>
            <th className="px-6 py-3">
              <div className="h-4 w-16 animate-pulse rounded bg-gray-300"></div>
            </th>
            <th className="px-6 py-3">
              <div className="h-4 w-24 animate-pulse rounded bg-gray-300"></div>
            </th>
            <th className="px-6 py-3">
              <div className="h-4 w-20 animate-pulse rounded bg-gray-300"></div>
            </th>
            <th className="px-6 py-3">
              <div className="h-4 w-16 animate-pulse rounded bg-gray-300"></div>
            </th>
          </tr>
        </thead>
        <tbody className="divide-y divide-gray-200">
          {[...Array(5)].map((_, index) => (
            <tr key={index}>
              <td className="px-6 py-4">
                <div className="h-4 w-8 animate-pulse rounded bg-gray-200"></div>
              </td>
              <td className="px-6 py-4">
                <div className="h-4 w-32 animate-pulse rounded bg-gray-200"></div>
              </td>
              <td className="px-6 py-4">
                <div className="h-4 w-40 animate-pulse rounded bg-gray-200"></div>
              </td>
              <td className="px-6 py-4">
                <div className="h-4 w-20 animate-pulse rounded bg-gray-200"></div>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

The key here is animate-pulse – It creates that nice breathing effect you see in modern loading states.

The Dashboard Page

// app/dashboard/page.tsx
import { Suspense } from "react";
import TableSkeleton from "../../components/TableSkeleton";
export const dynamic = 'force-dynamic';
interface User {
  id: number;
  name: string;
  email: string;
  username: string;
}
async function getUsers(): Promise<User[]> {
  const response = await fetch('<https://jsonplaceholder.typicode.com/users>');
  if (!response.ok) throw new Error('Failed to fetch users');
  return response.json();
}
async function UsersTable() {
  const users = await getUsers();
  return (
    <div className="overflow-hidden rounded-lg border border-gray-200">
      <table className="w-full">
        <thead className="bg-gray-50">
          <tr>
            <th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
              ID
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
              Name
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
              Email
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
              Username
            </th>
          </tr>
        </thead>
        <tbody className="divide-y divide-gray-200 bg-white">
          {users.map((user) => (
            <tr key={user.id} className="hover:bg-gray-50">
              <td className="px-6 py-4 text-sm text-gray-900">{user.id}</td>
              <td className="px-6 py-4 text-sm font-medium text-gray-900">{user.name}</td>
              <td className="px-6 py-4 text-sm text-gray-500">{user.email}</td>
              <td className="px-6 py-4 text-sm text-gray-500">@{user.username}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
export default function DashboardPage() {
  return (
    <div className="min-h-screen bg-gray-50 p-8">
      <h1 className="mb-8 text-3xl font-bold">User Dashboard</h1>
      <Suspense fallback={<TableSkeleton />}>
        <UsersTable />
      </Suspense>
    </div>
  );
}

Here’s what’s happening:

  • The page renders immediately with the h1 header
  • React Suspense shows <TableSkeleton /> while UsersTable fetches data
  • Once the API call completes, the real table replaces the skeleton
  • export const dynamic = 'force-dynamic' tells Next.js to always fetch fresh data

The beauty of this pattern? The page feels incredibly fast because the static content (header, layout) renders instantly. Only the data-dependent parts show a loading state.

When to use this: Perfect for initial page loads, SEO-critical content, and database queries. Best perceived performance because the page starts rendering immediately.Pairing these performance improvements with SEO by Highsoftware99.com can further help optimize your site’s visibility and overall search performance.

Key advantage: Data never touches the client’s fetch API-it’s all handled on the server and streamed as HTML.

Pattern 3: Client Component with use() Hook

React 19 introduced the use() hook, which modernizes how we handle client-side data fetching with Suspense.

Important distinction: This is a Client Component that fetches data from the browser, not the server. It’s a cleaner alternative to useState, but still does client-side fetching. The use() hook integrates promises with Suspense boundaries elegantly.

The Card Skeleton Component

First, I created a skeleton for a user profile card:

// components/CardSkeleton.tsx
export default function CardSkeleton() {
  return (
    <div className="w-full rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
      <div className="flex items-center space-x-4">
        <div className="h-12 w-12 animate-pulse rounded-full bg-gray-300"></div>
        <div className="flex-1 space-y-2">
          <div className="h-4 w-3/4 animate-pulse rounded bg-gray-300"></div>
          <div className="h-3 w-1/2 animate-pulse rounded bg-gray-200"></div>
        </div>
      </div>
      <div className="mt-4 space-y-3">
        <div className="h-3 w-full animate-pulse rounded bg-gray-200"></div>
        <div className="h-3 w-5/6 animate-pulse rounded bg-gray-200"></div>
        <div className="h-3 w-4/6 animate-pulse rounded bg-gray-200"></div>
      </div>
    </div>
  );
}

The Profile Page

// app/profile/page.tsx
"use client";
import { Suspense, use, useMemo } from "react";
import CardSkeleton from "../../components/CardSkeleton";
interface UserProfile {
  id: number;
  name: string;
  email: string;
  username: string;
  phone: string;
  website: string;
  company: { name: string };
}
async function fetchUserProfile(): Promise<UserProfile> {
  const response = await fetch('<https://jsonplaceholder.typicode.com/users/1>');
  if (!response.ok) throw new Error('Failed to fetch profile');
  return response.json();
}
function UserProfileCard({ profilePromise }: { profilePromise: Promise<UserProfile> }) {
  const profile = use(profilePromise);
  
  return (
    <div className="w-full rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
      <div className="flex items-center space-x-4">
        <div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-600 text-2xl font-bold text-white">
          {profile.name.charAt(0)}
        </div>
        <div className="flex-1">
          <h2 className="text-xl font-semibold text-gray-900">{profile.name}</h2>
          <p className="text-sm text-gray-500">@{profile.username}</p>
        </div>
      </div>
      <div className="mt-4 space-y-2">
        <p className="text-sm">
          <span className="font-medium">Email:</span> {profile.email}
        </p>
        <p className="text-sm">
          <span className="font-medium">Phone:</span> {profile.phone}
        </p>
        <p className="text-sm">
          <span className="font-medium">Website:</span> {profile.website}
        </p>
        <p className="text-sm">
          <span className="font-medium">Company:</span> {profile.company.name}
        </p>
      </div>
    </div>
  );
}
export default function ProfilePage() {
  const profilePromise = useMemo(() => fetchUserProfile(), []);
  
  return (
    <div className="min-h-screen bg-gray-50 p-8">
      <h1 className="mb-8 text-3xl font-bold">User Profile</h1>
      
      <Suspense fallback={<CardSkeleton />}>
        <UserProfileCard profilePromise={profilePromise} />
      </Suspense>
    </div>
  );
}

Breaking it down:

  • "use client" – Makes this a client component
  • useMemo(() => fetchUserProfile(), []) – Creates the promise once and memoizes it
  • use(profilePromise) – The new React 19 hook that unwraps the promise
  • Suspense boundary shows the skeleton while the promise resolves

The cool part? The use() hook suspends the component until the promise resolves, which triggers the Suspense boundary. It’s cleaner than useState but achieves the same result: client-side data fetching.

When to use this: When you want modern React patterns with Suspense integration for client-side features. Better than useState for simple data fetching, but note that data is still fetched from the browser (not server streaming).

Reality check: Both this pattern and useState fetch data from the client. The difference is the API (use() + Suspense vs. useState + useEffect), not the architecture.

Pattern 4: Client Component with useState (The Traditional Approach)

The traditional useState The pattern gives you complete manual control. Like the Profile page, this also fetches data from the browser, just with a different API.

The key difference from Profile: useState gives you explicit control over loading, error, and success states. The Profile page’s use() hook abstracts this into Suspense boundaries.

// app/products/page.tsx
"use client";
import { useState, useEffect } from "react";
interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}
const RingSpinner = () => (
  <div className="flex items-center justify-center">
    <div className="h-12 w-12 animate-spin rounded-full border-4 border-blue-200 border-t-blue-600 border-r-blue-600"></div>
  </div>
);
export default function ProductsPage() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  
  useEffect(() => {
    const fetchPosts = async () => {
      setLoading(true);
      setError(null);
      try {
        const response = await fetch('<https://jsonplaceholder.typicode.com/posts?_limit=10>');
        if (!response.ok) throw new Error('Failed to fetch posts');
        const data = await response.json();
        setPosts(data);
      } catch (error) {
        setError(error instanceof Error ? error.message : 'An error occurred');
      } finally {
        setLoading(false);
      }
    };
    
    fetchPosts();
  }, []);
  
  if (loading) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-gray-50">
        <div className="text-center">
          <RingSpinner />
          <p className="mt-4 text-lg font-medium text-gray-700">Loading posts...</p>
          <p className="mt-2 text-sm text-gray-500">Fetching data from API</p>
        </div>
      </div>
    );
  }
  
  if (error) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-gray-50">
        <div className="text-center">
          <h2 className="mb-2 text-xl font-semibold text-gray-900">Error Loading Posts</h2>
          <p className="text-gray-600">{error}</p>
          <button
            onClick={() => window.location.reload()}
            className="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
          >
            Try Again
          </button>
        </div>
      </div>
    );
  }
  
  return (
    <div className="min-h-screen bg-gray-50 p-8">
      <h1 className="mb-8 text-3xl font-bold">Blog Posts</h1>
      <div className="grid gap-6 md:grid-cols-2">
        {posts.map((post) => (
          <div key={post.id} className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
            <h3 className="mb-3 text-lg font-semibold text-gray-900 capitalize">
              {post.title}
            </h3>
            <p className="text-sm text-gray-600 line-clamp-3">{post.body}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

Why is this pattern still valuable?

  1. Full control – You decide exactly when and how to show loading states
  2. Error handling – Easy to add custom error UI with a retry button
  3. Loading state variations – Can show different UI based on what’s loading
  4. Familiar – If you’ve used React before, this is comfortable territory

When to use this: When you need fine-grained control over loading states, complex error handling, or when you’re working with existing code that uses this pattern.

Understanding the Real Differences

Where Data Actually Fetches

PatternComponent TypeFetch LocationRenders
DashboardServerServerStreams from server as HTML
ProfileClientBrowserHydrates in browser after fetch
ProductsClientBrowserHydrates in browser after fetch

The Key Insight

Profile and Products are more similar than different!

Both fetch data from the browser using JavaScript. The difference is:

  • Profile: Uses React 19’s use() hook + Suspense (modern, declarative)
  • Products: Uses useState + useEffect (traditional, imperative)

Spinners vs Skeletons: When to Use Each

I get asked this a lot: “Should I use a spinner or a skeleton?”

Here’s my rule of thumb:

Use Spinners When:

  • The content structure is unknown or varies significantly
  • Loading is quick (< 1 second typically)
  • Full-page or modal loads
  • Generic “something is happening” feedback

Use Skeletons When:

  • You know exactly what content will appear
  • Loading might take longer (> 1 second)
  • Partial page updates
  • You want to set user expectations about the layout

In the demo:

  • Dashboard & Profile use skeletons because we know we’re loading a table and a card
  • The products page uses a spinner for a full-page load

Both have their place. The key is matching the loading UI to user expectations.


If you’re looking for a Tailwind Components library, then check out FlyonUI.

flyonui

Also available in the pro version. It includes

Check it out now!


Final Thoughts

Loading states might seem like a minor detail when you’re racing to ship features, but they’re one of the first things users notice, especially when they’re done poorly. A blank screen or unresponsive UI creates anxiety. A well-designed loading state creates confidence.

You don’t need fancy libraries or complex animations. Tailwind CSS and Next.js give you everything you need to build professional loading experiences. The real challenge is deciding which pattern fits your use case and implementing it thoughtfully.

Start with the basics: show users that something is happening. Then level up: show them what’s happening and how long it might take. Your future users will thank you.

Additional Resources

If you want to dive deeper:

If this post helped you build better loading experiences, I’d love to hear about it. And if you run into issues or have questions about specific patterns, drop a comment-I’ll do my best to help out.

Happy coding! 🚀

Ajay Patel

CEO & Co-Founder

With over 15 years in the tech industry, I’ve honed my skills as an entrepreneur, programmer, and innovator. As the co-founder of Clevision, I've created ThemeSelection, PixInvent, FlyonUI & ShadcnStudio.

Get notified about upcoming Premium & Free themes, Unique promo codes and Sales 🎉 !

] Announcements
Announcements Banner