TL;DR Export a skeleton from loading.tsx and Next.js shows it while the page streams in. Wrap each slow panel in its own Suspense so one slow query doesn't hold back the rest. Make every block the same size as the real content so nothing jumps, hide the blocks with aria-hidden, announce loading once, and turn the shimmer off with motion-reduce.
A skeleton loader is a grey block the size of the content that's coming, with a soft light sweeping across it. In Next.js App Router you get one for free at the route level by exporting it from loading.tsx, and you can go finer by wrapping each slow panel in <Suspense> with its own skeleton as the fallback. The shimmer is one keyframe in Tailwind v4 and a pseudo-element. The part that matters more than the shine is that every skeleton has the same size as the thing it stands in for.
I make qodesign, a set of animated React + TypeScript + Tailwind components. Our free Orbit Dashboard has KPI cards, a latency chart and an events table, so it's the layout I'll build skeletons for here. The code below was written for this post and compiled with TypeScript in strict mode against React 19 and Next.js 15. It needs no library.
Tailwind ships animate-pulse, which fades the block's opacity in and out on a 2 second loop. It's fine, and it's one class. A shimmer is a gradient that travels across the block. It reads as "loading" a bit faster on dark dashboards, where a pulse between two dark greys is hard to see. Pick one and use it everywhere. Mixing them on one screen looks like two different apps.
Day / Night Toggle running live. A theme switch with a sun that sets into a starry night.
Tailwind v4 lets you define an animation and its keyframes inside @theme in your CSS. Add this to app/globals.css and you get an animate-shimmer utility:
The keyframes set both ends on purpose. In v4, -translate-x-full sets the CSS translate property, and the animation moves transform, so mixing the two adds the offsets together. Putting both positions in the keyframes keeps it predictable. On Tailwind v3 the same animation goes in theme.extend.keyframes and theme.extend.animation in tailwind.config.js, and the gradient class is bg-gradient-to-r where v4 uses bg-linear-to-r.
The light is an ::after layer that slides across with overflow-hidden clipping it. It moves with transform, so it doesn't make the browser redo layout on every frame. aria-hidden keeps screen readers from reading a pile of empty boxes, and motion-reduce:after:hidden turns the sweep off for people who asked their system for less motion. A still grey block is a perfectly good loading state.
There's no server-only or client-only code in it, so it works in Server Components and in loading.tsx without "use client". Because it takes every normal div prop, you size each one with classes and pass style when you need it.
The most common mistake is a skeleton that doesn't match the page. A spinner in the middle of the screen, or three generic bars, means everything jumps when the data lands. Build the skeleton out of the same grid, gaps and heights as the real panels:
Each KPI card has the label, the big number and the sparkline in the same spots as the real card. The chart placeholder is the same h-72 as the chart. The table rows are the real row height, h-12.
The table rows get different widths so they look like text. The widths come from the row index, not Math.random(). A random width renders one number on the server and another in the browser, and React reports a hydration mismatch. We wrote up that error in fixing hydration errors from dates and random values.
Put a loading.tsx next to page.tsx and Next.js wraps the page in a Suspense boundary with this as the fallback. The Next.js docs for loading.js say the fallback is prefetched, so navigation shows it straight away, and shared layouts stay interactive while the segment loads.
The wrapper has role="status" and one line of screen reader text. That way someone using a screen reader hears "Loading dashboard" once, while the blocks themselves stay hidden.
There's a second reason to add it. According to the Next.js guide to linking and navigating, a dynamic route is either not prefetched at all or only partly prefetched when it has a loading.tsx. Without one, a click on a link to a dynamic page can sit there with nothing happening until the server answers.
loading.tsx swaps the whole page at once, so the slowest query decides when anyone sees anything. If your panels load from different sources, give each one its own boundary:
tsx
// app/dashboard/page.tsximport { Suspense } from "react";import { ChartSkeleton, KpiSkeleton, TableSkeleton } from "@/components/DashboardSkeleton";import { Kpis } from "@/components/Kpis";import { LatencyChart } from "@/components/LatencyChart";import { EventsTable } from "@/components/EventsTable";export default function DashboardPage() { return ( <main className="space-y-4 p-6"> <Suspense fallback={<KpiSkeleton />}> <Kpis /> </Suspense> <Suspense fallback={<ChartSkeleton />}> <LatencyChart /> </Suspense> <Suspense fallback={<TableSkeleton />}> <EventsTable /> </Suspense> </main> );}
Each panel is an async Server Component that fetches its own data:
LatencyChart and EventsTable are built the same way. Now the KPIs can appear while the events table is still waiting, and each skeleton is replaced by the panel it was shaped like. React's Suspense reference covers the finer cases, like revealing panels together or nesting boundaries.
Some data only exists on the client, like a list you refetch on a button press. The skeleton works there too. Show it while the state is empty, and mark the region busy:
aria-busy tells assistive technology that a region is being updated, so it can wait until the content is ready. The alive flag stops a late answer from setting state after the component has unmounted.
When you ask Cursor, v0 or Lovable for "a loading state", these come back often:
A centred spinner in loading.tsx instead of a skeleton of the page, so the whole layout jumps when the page arrives.
Skeleton heights that don't match the real panels. The page shifts by the difference, which shows up as layout shift.
Math.random() widths, which cause a hydration mismatch.
A shimmer that runs for everyone, ignoring reduced motion.
One Suspense boundary around the whole page, so a single slow query holds back every panel.
A prompt that avoids most of them: "Build a loading.tsx skeleton that uses the same grid, gaps and heights as page.tsx, hides the blocks with aria-hidden, announces loading once with role=status, turns the shimmer off with motion-reduce, and wraps each data panel in its own Suspense."
The Orbit Dashboard is free under the MIT licence, with no email needed. It's a dark ops dashboard: six KPI cards that count up and draw sparklines, a live service map, a p50/p95/p99 latency chart and a filterable events table. Its panels are separate components, so each one can sit inside its own Suspense with a skeleton shaped like it. There's a short guide on our observability dashboard page, and the folder has a PROMPT.md you can paste into your AI editor. If you'd like one of our paid $3 pieces as well, pick one free here.
Create loading.tsx in the route folder and return a skeleton of the page. Next.js wraps page.tsx in a Suspense boundary and shows your skeleton while the page streams in. For finer control, wrap each slow Server Component in <Suspense fallback={...}> with a skeleton shaped like that component.
Either works. animate-pulse is built into Tailwind and needs no setup. A shimmer takes one keyframe in @theme and is easier to see on dark backgrounds. Use one style across the whole app, and turn it off with motion-reduce: either way.
The skeleton and the real content are different sizes. Copy the real component's grid, gaps and heights into the skeleton, and keep text placeholders the height of the text line. A spinner always causes a jump, because it has none of the page's shape.
No. A skeleton with only classes and markup runs as a Server Component, which is what loading.tsx and Suspense fallbacks expect. You only need "use client" in the component that fetches data in the browser, like the ClientEvents example above.