Page Loading UX
Page Loading UX — The Place of loading.tsx, Suspense, and Fade-in
A skeleton flicker on a fast page is unpleasant. A long blank screen on a slow page is anxious. Both share the same root cause — fallback only sits at the route level.
1. Two units of fallback
Next.js App Router accepts fallbacks in two units.
app/<segment>/loading.tsx— fallback shown while the directory segment's page receives the RSC payload. Auto-wired per route.<Suspense fallback={...}>— a fallback applied only to one zone inside a page. Wraps the part the server component awaits and streams just that part.
The same page can carry both. The outer is loading.tsx and the inside is Suspense — partial fallback only on slow regions.
2. loading.tsx
app/(group)/page.tsx ← async function Page() { await db.fetch(...) }
app/(group)/loading.tsx ← export default function Loading() { ... }
When the router begins to navigate to a new segment, it immediately mounts loading.tsx. Once the page's RSC payload arrives, loading is unmounted and the page is mounted. From the user's view, "click → fallback → body" is a natural flow.
Pitfall: placing loading.tsx inside a group segment applies the fallback to every route in that group. Even static pages (/help, /privacy) and client useEffect pages flicker the fallback, which feels unpleasant on fast routes.
3. Suspense
export default async function Page() {
return (
<>
<Header />
<Suspense fallback={<TableSkeleton />}>
<SlowList />
</Suspense>
</>
);
}
The page itself renders immediately and shows Header right away. Only while <SlowList> awaits, TableSkeleton sits in that place. When the RSC payload arrives, only the zone is streaming swap.
The advantage is progressive disclosure aligned with user perception. The disadvantage is that page.tsx must finish synchronously for the fallback to appear, so data fetch must be split into an async child component.
4. Where does the fade-in go?
Adding a short fade-in to route transitions reduces fallback ↔ body jank. The most KISS place is key={pathname} + a CSS animation on the layout's children wrapper:
"use client";
import { usePathname } from "next/navigation";
export default function Layout({ children }) {
const pathname = usePathname();
return (
<main>
<div key={pathname} className="animate-fade-in">{children}</div>
</main>
);
}
When the route changes, the wrapper unmounts and remounts → .animate-fade-in (e.g., 0.2s ease-out) re-runs each time. Handling prefers-reduced-motion: reduce in globals.css automatically guarantees accessibility.
5. Libraries and the View Transitions API
Packages like framer-motion, motion, and next-view-transitions support fancier transitions. However, when only page-level fade-in is needed, a single CSS keyframe is sufficient and the bundle cost is significant. Adopt only at places where fancy transitions (slide, scale, page-shared elements) are needed.
The View Transitions API is a browser-native API. Chrome 111+ supports it, with parts unsupported in Safari/Firefox. Without a well-designed fallback in production, some users see jank. Premature for production.
6. A single external spinner
Place a spinner in one global layout and toggle it via the router's useLinkStatus (Next 16) or router.events. Simple, but the fallback is far away, so layout shift and positional perception feel awkward. Cannot give a precise zone fallback.
7. Route classification
Splitting into three branches simplifies thinking.
| Type | Data dependence | Where to put |
|---|---|---|
| server await | page.tsx is async + await getDb()/fetch() | route-level loading.tsx or <Suspense> inside the page |
| client useEffect | "use client" + fetch inside useEffect | do not put a page-level loading.tsx. Form/nav/layout immediate; only data area uses an in-view loading |
| static | no data fetch (policy docs, forms, redirects, etc.) | absolutely no loading.tsx |
Principle: placing fallbacks on data-independent pages cuts user experience. Fallbacks belong only where there is cost.
8. A case study — the trap of group-level loading.tsx
In one project's web-app, a single app/(route)/loading.tsx once acted as the fallback for every route in the group (48 pages). A dot spinner flickered in fullscreen. 11 static pages (/help, /privacy, /terms, /auth/signup, etc.) and 24 client useEffect pages (/wishlist, /feed, /mypage, etc.) all passed through this spinner once each — even though they had no data dependence at all.
The fix is two lines.
① delete (route)/loading.tsx
→ eliminate the group-level fallback itself
② only the 10 of 13 data-dependent routes that don't have their own Suspense get a per-route loading.tsx
→ skeletons matching the domain shape (table, card grid, detail, profile)
→ leave pages that already have Suspense embedded as is
As a side effect, adding route transition fade-in to the layout wrapper — fast static pages fade in directly without a spinner; slow server-await pages fade in after a precise skeleton. With both flows sharing the same 0.2s visual language, awkwardness disappears.
The key lesson: fallback location decides user experience. Putting it uniformly at the group level is convenient, but forces a burden on fast routes. Per-route — or even better, per-zone inside the page — fallbacks satisfy both KISS and user friendliness.
9. Common pitfalls
When <Suspense> does not stream — if page.tsx itself is async, Suspense does not appear until the outer page completes. Data fetch must be split into an async child component for the zone fallback to be meaningful.
<Suspense> in a client component — in client trees it has almost no meaning except for lazy import. The streaming effect lives only in the server component tree.
Missing key={pathname} — the layout itself is persistent, so when the wrapper is reused, only the children swap on the same element — animation does not re-run. Only when key changes does React treat it as unmount/mount.
Interaction between prefetch and loading.tsx — <Link prefetch> receives the RSC payload in advance, so the fallback rarely shows (a good thing). Conversely, places with prefetch={false} guarantee fallback after click. An intentional exposure place.
Lighthouse LCP measurement — a large placeholder inside a fallback can become LCP. Matching the skeleton's height to the first viewport of the body reduces layout shift and stabilizes LCP.
Closing thoughts
Loading UX is decided almost entirely by fallback location. Applying it uniformly per route forces a burden on fast pages, while skipping fallback leaves slow pages on a blank screen. Setting fallback location per route and per zone is the flow that satisfies both KISS and user experience.
Next
- (end of frontend)
We refer to Next.js loading UI, React Suspense, View Transitions API, and WCAG prefers-reduced-motion.