Loading states¶
Every page that awaits data on the server now gets a loading.tsx sibling. The skeleton renders the same chrome the page will render — real <Header />, real <Footer />, then placeholder sections shaped like the real content. This is the cheapest CLS win we have, and the cheapest "feels fast" win.
When to use loading.tsx vs inline <Suspense>¶
| Use | Tool |
|---|---|
| Whole route is awaiting one or more server fetches (Woo, Yoast, fpApi) | app/<route>/loading.tsx — Next.js wraps the whole segment in <Suspense> for free |
| One slow island inside an otherwise instant page (e.g. a TikTok oEmbed inside the home page) | Inline <Suspense fallback={<Skeleton …/>}> around just that subtree |
| Client-side state machines (button "Adding…" labels, form submit spinners) | Local component state — not these primitives |
Rule of thumb: if page.tsx is async function and awaits anything outside of params, give it a loading.tsx sibling. If it's synchronous (a placeholder, a fully-static page) it doesn't need one.
The <Skeleton /> primitive¶
Single component, three exports — all in apps/web/components/ui/Skeleton.tsx:
import { Skeleton, SkeletonText, SkeletonCard } from "@/components/ui/Skeleton";
// 1. Bare primitive — sizing comes from the caller's classes.
<Skeleton className="h-10 w-40 rounded-full" />
// 2. Stacked text bars. Last bar shortens to ~70% so it reads as a paragraph.
<SkeletonText lines={3} />
// 3. Atlas/Shop product-card silhouette: square hero + title + body + price.
<SkeletonCard />
All three render <div> only — no JS, no client boundary, no motion library. The pulse is Tailwind's animate-pulse. Fill is bg-white/[0.06] so it sits under the border-border/60 cards without fighting them.
Routes covered today¶
| Route | File | Mirrors |
|---|---|---|
/ (last-resort fallback) |
app/loading.tsx |
Centred, pulsing logo |
/shop |
app/shop/loading.tsx |
Title, category pills, 12-card grid |
/p/[slug] |
app/p/[slug]/loading.tsx |
Gallery + info column + related rail |
/atlas |
app/atlas/loading.tsx |
Intro block + 9 family cards |
/atlas/[slug] |
app/atlas/[slug]/loading.tsx |
21:9 hero + intro + fruit grid + siblings |
/cart |
app/cart/loading.tsx |
3 line items + totals + checkout button |
/account |
app/account/loading.tsx |
Greeting + quick actions + orders |
/checkout is currently a static placeholder (no server fetch) so it deliberately has no loading.tsx.
CLS tips¶
- Match real dimensions. A skeleton that's 200px tall replaced by content that's 320px tall costs CLS. Keep the shape close —
aspect-square, fixed heights on titles (h-12), known card grid spacing. - Reuse the real Header / Footer. They're the heaviest parts of the layout and have known heights — putting them in the skeleton means their pixels never move.
- Prefer
aspect-*over fixedh-*for media. Lets the placeholder reflow with viewport size the same way the real<Image />will. - Skip animations beyond
animate-pulse. Anything fancier costs main-thread time during the period the user is already waiting on data. - Don't put text inside
<Skeleton />. It'saria-hidden. If you need announce-on-load copy, render a separate<span className="sr-only">Loading…</span>.
Adding a new route¶
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { Skeleton, SkeletonText, SkeletonCard } from "@/components/ui/Skeleton";
export default function RecipesLoading() {
return (
<>
<Header />
<main className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<Skeleton className="mb-8 h-12 w-44" />
<SkeletonText lines={2} className="mb-8 max-w-2xl" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
</div>
</main>
<Footer />
</>
);
}
That's the whole pattern. No new dependencies, no motion library, just Tailwind.