Skip to content
Lantorian

Next.js8 min read

Loading and skeletons

While data loads, show its silhouette, not a spinner. Users watch the page build up, and nothing jumps when content arrives.

Why a skeleton

A spinner says nothing about what's coming and causes a layout shift (CLS, one of the Core Web Vitals) when content replaces it. A skeleton already takes the right space. Watch the dashed box under each card when you reload.

Spinner

Loading profile
Next element: shifts while loading

Skeleton

Loading profile
Next element: stays in place
Reload the demo and watch the dashed box: on the left it jumps, on the right it stays put.

What happens while loading

With the App Router, the server streams the page: the shell and skeletons go out immediately, then each block wrapped in Suspense replaces its skeleton as soon as its data is ready.

  1. 0 msNavigationThe browser requests /en/invoices.
  2. 120 msShell + loading.tsxLayout, title and skeletons shown without waiting for data.
  3. 550 msSuspense: statsFast query done, its skeleton is replaced.
  4. 1300 msSuspense: tableSlow query done, the table appears without shifting the page.
Slowed down twice. Each block arrives independently: a slow query doesn't block the rest of the page.

Which tool for which loading state

SituationTool
Navigating to a new pageloading.tsx in the route folder
One slow block in a fast pageSuspense with a skeleton as fallback
Submitting a formuseFormStatus or useActionState: disabled button reading "Saving…"
Client-side filter or tabuseTransition: keep the old content dimmed, with aria-busy

The twin skeleton

Every data-loading component has a .skeleton.tsx file next to it, with the same structure and heights. Fixed text (column headers) renders for real, only data is replaced.

src/features/invoices/components/invoice-table.skeleton.tsx
import { useTranslations } from 'next-intl';
import { Skeleton } from '@/components/ui/skeleton';

export function InvoiceTableSkeleton({ rows = 8 }: { rows?: number }) {
  const t = useTranslations('invoices.table');

  return (
    <div role="status" aria-busy="true">
      <span className="sr-only">{t('loading')}</span>
      <table className="w-full">
        <thead>
          <tr>
            <th>{t('number')}</th>
            <th>{t('customer')}</th>
            <th className="text-right">{t('total')}</th>
          </tr>
        </thead>
        <tbody>
          {Array.from({ length: rows }, (_, i) => (
            <tr key={i} className="h-12 border-t">
              <td><Skeleton className="h-4 w-24" /></td>
              <td><Skeleton className="h-4 w-40" /></td>
              <td><Skeleton className="ml-auto h-4 w-16" /></td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

loading.tsx and Suspense

loading.tsx covers the whole page during navigation. For finer loading, wrap each slow block in its own Suspense. Add a key when search params change.

src/app/[locale]/(app)/invoices/loading.tsx
import { InvoiceTableSkeleton } from '@/features/invoices/components/invoice-table.skeleton';
import { PageHeaderSkeleton } from '@/components/layout/page-header.skeleton';

// app/[locale]/(app)/invoices/loading.tsx
// Shown instantly on navigation, before the page's data is ready
export default function Loading() {
  return (
    <>
      <PageHeaderSkeleton />
      <InvoiceTableSkeleton />
    </>
  );
}

Avoid

loading.tsx
export default function Loading() {
  return (
    <div className="flex h-screen items-center justify-center">
      <Spinner />  {/* whole page blank, then everything jumps */}
    </div>
  );
}

Do

loading.tsx
export default function Loading() {
  return (
    <>
      <PageHeaderSkeleton />         {/* same height as the header */}
      <InvoiceTableSkeleton rows={8} /> {/* same rows as the table */}
    </>
  );
}

Pending actions

For a mutation, no skeleton: keep the form and show the state in the button. The label comes from JSON (common.actions.saving).

src/components/forms/submit-button.tsx
'use client';

import { useTranslations } from 'next-intl';
import { useFormStatus } from 'react-dom';
import { Button } from '@/components/ui/button';

export function SubmitButton() {
  const t = useTranslations('common.actions');
  const { pending } = useFormStatus();

  return (
    <Button type="submit" disabled={pending} aria-busy={pending}>
      {pending ? t('saving') : t('save')}
    </Button>
  );
}

Rules

  • Same box: the skeleton has the final content's height and grid. Nothing jumps when data arrives.
  • Accessible: role="status", aria-busy and a translated sr-only text ("Loading invoices").
  • Reduced motion: the pulse animation is disabled with motion-reduce:animate-none.
  • No skeleton under 300 ms in a client component: a grey flash is worse than a short wait.
  • Empty and error states get a design too: error.tsx offers a retry, the empty state offers an action.