Our marketing site has no loading states at all. Not a spinner, not a skeleton, not a suspense boundary. The dashboard on the same domain has eleven skeleton files totalling 719 lines.
That is not an inconsistency, it is the same decision applied to two different kinds of page, and the first half of it is visible from a terminal:
curl -sI https://pub-trivia.app/features | grep -i 'x-nextjs-prerender\|x-vercel-cache'
# x-nextjs-prerender: 1
# x-vercel-cache: HIT
70 of the 72 URLs in our sitemap answer like that. They were rendered at build time, they are files on a CDN, and there is no moment at which a loading state could appear. Adding a loading.tsx to a prerendered route is building a UI for a state the route cannot enter.
The dashboard cannot work that way. Every page in it is a query keyed to one account: your venues, your tables, your question packs, your session history, your plan. None of it can be prerendered and none of it can be cached between users, so every navigation has a gap, and the gap is where the eleven files live.
What eleven files buys that one would not
Next.js lets you put a single loading.tsx at the top of a segment and be done. We have one per route instead:
| File | Lines | Skeleton elements |
|---|---|---|
dashboard/plans/loading.tsx |
97 | 13 |
dashboard/billing/loading.tsx |
92 | 12 |
dashboard/settings/loading.tsx |
80 | 14 |
dashboard/packs/[id]/edit/loading.tsx |
72 | 21 |
dashboard/help/loading.tsx |
66 | 15 |
dashboard/loading.tsx |
58 | 12 |
dashboard/host/loading.tsx |
53 | 13 |
dashboard/tables/loading.tsx |
51 | 10 |
dashboard/history/loading.tsx |
51 | 12 |
dashboard/packs/loading.tsx |
50 | 12 |
dashboard/packs/new/loading.tsx |
49 | 12 |
146 skeleton elements. The primitive they are all made of lives in a fourteen line file:
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
A grey box that pulses. Everything else is arrangement, and the arrangement is the point, because a route-level skeleton can be the shape of the thing that is coming and a shared one cannot.
Here is the packs skeleton, next to the page it stands in for:
// app/dashboard/packs/loading.tsx
<div className="container py-8 max-w-5xl">
<div className="flex items-start justify-between gap-4 mb-6">
<div className="space-y-2">
<Skeleton className="h-8 w-44" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-8 w-28 rounded-md" />
</div>
<Skeleton className="h-4 w-24 mb-6" />
{/* Pack cards grid, matching the PackCard layout exactly */}
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
// app/dashboard/packs/page.tsx
<div className="container py-8 max-w-5xl">
...
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
Same container, same max width, same gap, same breakpoints. The skeleton renders a header block the exact height of the real header, a count line where the real count line goes, and a grid that breaks to two columns and then three at the same widths. When the data lands, the boxes are replaced by cards in place. Nothing reflows, nothing jumps, and the eye has nothing to track.
A single shared spinner centred in the segment gets you none of that. The page appears all at once, at a different height, in a different arrangement, after a blank area of indeterminate size. That transition is cheap to build and it is the one that feels slow, because perceived speed is mostly about whether the layout settles once or twice.
It also matters that these are route-level and not inside the pages. The dashboard header and its nav live in a layout above them, so during a navigation the chrome never unmounts. You click "Packs", the nav highlight moves immediately, and the skeleton appears underneath it. The shell is not part of the loading state because the shell is not loading.
Two things about this that are not good
Nothing checks that the skeleton still matches. The grid classes above agree today because somebody copied them. There is no test in the suite that compares a skeleton to the component it mirrors, and there is no obvious way to write one: the real page needs a session and a database, which is precisely the condition under which the skeleton never renders. So the honest status is that these eleven files are documentation of the layout as it was on the day each one was written, and the first symptom of drift is a flicker that only the person who changed the grid will see, in the half second before their own data arrives.
The containment is that the skeletons use the same Card, CardHeader, CardContent and CardFooter components as the real cards, so the parts that are components rather than class strings do move together. The class strings are the exposed surface.
The number of placeholders is a guess. packs/loading.tsx draws six cards. history/loading.tsx draws six rows. dashboard/loading.tsx draws two of one thing and four of another. Those numbers are what a busy account looks like, which means a brand new account sees six pulsing pack cards for 300 milliseconds and then this:
{packs.length === 0 ? (
<h3 className="text-base font-semibold">No packs available</h3>
Six promises of content followed by none. We have not fixed it and the fix is not obvious either, because the skeleton by definition runs before anyone knows how many rows there are. The options are to draw fewer, to draw a count remembered from the last visit, or to decide that a new account's first load is the one case where a slightly oversold skeleton is the least of your onboarding problems. We picked the third, which is a choice and not an answer.
Seeing both halves
The public half is checkable right now, and the interesting thing about it is the absence:
curl -sI https://pub-trivia.app/pricing | grep -i 'x-vercel-cache'
# x-vercel-cache: MISS
/pricing is one of the two URLs in our sitemap that is rendered per request, because it resolves a display currency from the request. Even that one has no loading state, because the work happens on the server before the response starts rather than in the browser after it arrives. Server rendering moves the gap somewhere the user cannot see it, and that is the real reason the marketing site has no skeletons: not because it is fast, but because there is no point in the page's life where it is half built in front of you.
- Features, the guides and the fourteen quiz question pages are all prerendered. Navigate between them and nothing ever flashes a placeholder.
- Pricing is the per-request one, and still has nothing to show you while it thinks.
- The eleven skeletons are behind the login, and the free tier needs no card if you want to watch them. Throttle your connection to Slow 3G first, or you will miss them entirely, which is the outcome they were built for.
Top comments (0)