Skip to content

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

  1. 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.
  2. 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.
  3. Prefer aspect-* over fixed h-* for media. Lets the placeholder reflow with viewport size the same way the real <Image /> will.
  4. Skip animations beyond animate-pulse. Anything fancier costs main-thread time during the period the user is already waiting on data.
  5. Don't put text inside <Skeleton />. It's aria-hidden. If you need announce-on-load copy, render a separate <span className="sr-only">Loading…</span>.

Adding a new route

# next to your async page.tsx, e.g. app/recipes/loading.tsx
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.