Implement instant loading feedback with loading.tsx, Suspense boundaries, and skeleton components. Real patterns from my portfolio for perceived performance.
This content explains how to build effective loading states in Next.js App Router using loading.tsx, React Suspense, and skeleton UIs.
Route-level streaming with loading.tsx: Placing a loading.tsx file next to page.tsx in a route folder makes Next.js automatically wrap the page in a Suspense boundary and render the loading UI while data is fetched.
Skeleton UIs: Instead of spinners or blank screens, you render placeholder shapes (skeletons) that mimic the final layout.
Good skeletons:
Use the same container widths as the real page (e.g. container max-w-5xl).
Use the same grid layouts (e.g. grid md:grid-cols-2 lg:grid-cols-3).
Use the same spacing (e.g. py-16 md:py-24, mb-8, gap-4).
Use similar component shapes (cards, filters, avatars, search bars) so that when real data loads, there are minimal layout shifts.
Header skeleton for the page title.
Tag filter row with multiple small skeleton pills.
Search bar skeleton.
Featured post card skeleton.
Grid of post cards, each with skeletons for meta, title, and author.
This mirrors the actual blog layout, so content can stream in without jarring changes.
Header with title and subtitle skeletons.
Category filters as pill-like skeletons.
Search bar skeleton.
Featured project section using a two-column layout: image area skeleton + text/content skeletons.
Project grid with card skeletons (image, title, description, tags, and buttons).
Again, the skeleton structure matches the real projects page.
A simple Skeleton component (from shadcn/ui style) is used everywhere:
Uses animate-pulse for a subtle animation.
Uses bg-muted to stay consistent with the theme.
Accepts className so you can control width, height, border radius, etc.
This makes it easy to compose skeletons that match any layout.
Beyond route-level loading.tsx, you can use React's <Suspense> directly inside a page to stream independent sections:
Wrap Stats, Chart, DataTable, etc. each in their own <Suspense> with a dedicated skeleton fallback.
Faster sections appear earlier; slower ones stream in later.
Use loading.tsx when:
The whole page depends on the same data.
You want automatic route-level loading behavior.
The page is relatively simple.
Use manual <Suspense> when:
Different sections fetch different data.
Some sections are much faster than others.
You want fine-grained, parallel streaming.
Match real dimensions so there are no layout jumps.
Preserve semantic structure (same hierarchy of containers, grids, and sections).
Keep animation subtle (animate-pulse is usually enough).
Use theme-aware colors like bg-muted for consistency.
Limit skeleton counts (e.g. 3–6 items) to avoid overwhelming the user.
With these patterns:
Users see meaningful structure almost instantly (~50ms for skeleton render).
Data streams in progressively instead of blocking the whole page.
You avoid blank screens and reduce layout shifts as content loads.