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
Skeleton
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.
- 0 msNavigationThe browser requests /en/invoices.
- 120 msShell + loading.tsxLayout, title and skeletons shown without waiting for data.
- 550 msSuspense: statsFast query done, its skeleton is replaced.
- 1300 msSuspense: tableSlow query done, the table appears without shifting the page.
Which tool for which loading state
| Situation | Tool |
|---|---|
| Navigating to a new page | loading.tsx in the route folder |
| One slow block in a fast page | Suspense with a skeleton as fallback |
| Submitting a form | useFormStatus or useActionState: disabled button reading "Saving…" |
| Client-side filter or tab | useTransition: 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.
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>
);
}import { getTranslations } from 'next-intl/server';
import { getInvoices } from '../queries';
export async function InvoiceTable({ status, page }: { status?: string; page: number }) {
const [t, invoices] = await Promise.all([getTranslations('invoices.table'), getInvoices({ status, page })]);
if (invoices.data.length === 0) return <EmptyInvoices />;
return (
<table className="w-full">
<thead>
<tr>
<th>{t('number')}</th>
<th>{t('customer')}</th>
<th className="text-right">{t('total')}</th>
</tr>
</thead>
<tbody>
{invoices.data.map((invoice) => (
<tr key={invoice.id} className="h-12 border-t">
<td>{invoice.number}</td>
<td>{invoice.customer.name}</td>
<td className="text-right">{invoice.totalFormatted}</td>
</tr>
))}
</tbody>
</table>
);
}import { cn } from '@/lib/utils';
// components/ui/skeleton.tsx (shadcn/ui)
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md motion-reduce:animate-none', className)}
{...props}
/>
);
}
export { Skeleton };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.
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 />
</>
);
}export default async function DashboardPage() {
const t = await getTranslations('dashboard');
return (
<>
<h1>{t('title')}</h1> {/* static: renders immediately */}
<Suspense fallback={<StatsSkeleton />}>
<Stats /> {/* fast query, ~200 ms */}
</Suspense>
<Suspense fallback={<InvoiceTableSkeleton rows={5} />}>
<LatestInvoices /> {/* slow query, streams in later */}
</Suspense>
</>
);
}// Without a key, changing ?status= keeps the old table visible while loading.
// With a key, React shows the skeleton again for the new filter.
<Suspense key={`${status}-${page}`} fallback={<InvoiceTableSkeleton />}>
<InvoiceTable status={status} page={page} />
</Suspense>Avoid
export default function Loading() {
return (
<div className="flex h-screen items-center justify-center">
<Spinner /> {/* whole page blank, then everything jumps */}
</div>
);
}Do
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).
'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-busyand a translatedsr-onlytext ("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.tsxoffers a retry, the empty state offers an action.