Nakodo has 19 pages behind its login. It also has 19 loading.tsx files. That is not a coincidence I noticed afterwards, it is a rule, and this is what checking it looks like:

$ for d in $(find "src/app/(app)" -name page.tsx | xargs -n1 dirname); do [ -f "$d/loading.tsx" ] || echo "no loading: $d" done $ 

No output. Nineteen for 19. Together they are 885 lines, which is more code than any single page in the app, and the rest of this post is what is in them and the four decisions that shaped them.

The primitive is called Bone, and it is hatched

shadcn's Skeleton is 13 lines and ships with every install:

function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-muted", className)} {...props} /> } 

It is imported exactly once in this repository, by the support panel, and that is the only place in the product where a grey pulsing rectangle appears.

Everything else uses this instead:

// Loading skeletons. Hatching means "not yet observed", so each page's // skeleton is its own layout drawn in hatched shapes, with the text for // screen readers. export function Bone({ className, style }: { className?: string; style?: React.CSSProperties }) { return <div className={cn("hatch h-3 rounded-sm", className)} style={style} aria-hidden="true" />; } 

One hundred and fifty-nine uses. The difference between the two is one class. hatch is a diagonal line texture from the app's stylesheet, and the reason it is the skeleton fill is written in the comment: the whole product is styled as a field guide, where a hatched shape means a specimen that has not been observed yet. A thing that has not loaded is not a grey box, it is an unobserved record.

You can see the texture itself without signing in. The home page uses the same utility 36 times in its artwork, and if you grep its HTML you will find no animate-pulse anywhere, because nothing on the public pages ever loads late:

$ curl -s https://nakodo.app/ | grep -o hatch | wc -l 36 $ curl -s https://nakodo.app/ | grep -o animate-pulse | wc -l 0 

Three helpers on top of it, and 19 hand-drawn layouts

The shared file is 31 lines total. Besides Bone there is FieldBone, which is the label-over-input shape that appears 15 times across the settings and setup pages, and Loading, the wrapper every one of the 19 files uses:

export function Loading({ label, className, children }: { label: string; className?: string; children: React.ReactNode }) { return ( <div role="status" className={cn("motion-safe:animate-pulse", className)}> <span className="sr-only">{label}</span> {children} </div> ); } 

Three things are packed into those seven lines.

role="status" with an sr-only label, because a screen reader gets nothing at all from 40 hatched divs. The labels are written per page: "Loading your profile", "Loading the proposal", "Loading your campaigns". Twelve files in the repo carry a role="status", and this is the only one that covers 19 screens, because all 19 loading files render exactly one <Loading>.

motion-safe:animate-pulse rather than animate-pulse, so the one piece of motion in a skeleton is off for anybody who has asked their system to reduce motion. The individual Bones do not animate; the container does, once, for all of them.

And aria-hidden="true" is on Bone itself rather than on the wrapper, which means the author of a page skeleton cannot forget it.

The remaining 885 lines are the 19 layouts, and they are hand-drawn rather than generated. A settings page skeleton is the real card, with the real heading, and bones where the data goes:

export default function ProfileLoading() { return ( <Loading label="Loading your profile" className="space-y-5 rounded-lg border bg-card p-5 sm:p-6"> <div className="space-y-1.5"> <h2 className="font-sans text-base font-semibold tracking-normal">Profile</h2> <Bone className="w-80 max-w-full" /> </div> ... 

That <h2>Profile</h2> is the point of the whole approach. Eleven of the 19 loading files render at least one real heading, because the heading is not loading. It is a constant in the source of the page above it. Boning out text the server already knows makes the page flicker from a grey smear into a word for no reason, and it makes the skeleton say less than it could.

The cost is that the 19 files are 19 duplications of a layout, and nothing enforces that they stay in step with the pages they shadow. Three of them have grown large enough to extract into a shared component next to the page it belongs to, which is where skeleton.tsx files start appearing: the setup wizard's step strip, the replies list and the proposal form each have one, used by both the loading file and the page itself for its own internal pending states.

The function I would keep in any project

// Rows fade out towards the bottom, so a skeleton never reads as a full list. export const fade = (i: number, of: number) => ({ opacity: 1 - (i / of) * 0.7 }); 

Seven call sites, all of them lists: campaigns, creators, conversations, support threads, messages.

A skeleton list has to pick a number of rows, and whatever it picks is a lie, because the real answer is "three" or "forty" and nobody knows yet. Three solid rows read as "you have three campaigns". The same three rows at 100, 77 and 53 per cent opacity read as "a list, going on past here", which is both honest and the thing the eye expects of a list that is still arriving. The constant 0.7 means the last row keeps 30 per cent of its opacity, so it fades rather than disappearing.

It is one line, it took two minutes, and it is the single change that made the loading states stop looking like fake content.

The six Suspense boundaries, four of which have no fallback

loading.tsx is the route-level boundary that Next gives you for free. There are six explicit <Suspense> boundaries on top of it, and they split cleanly in two.

Two have real fallbacks, both in the campaign layout:

<Suspense fallback={<HeaderSkeleton />}> 

That layout wraps every campaign page, so without a boundary the layout's own queries would delay the page's loading.tsx from appearing at all. With it, the frame arrives immediately and the header and the tab strip fill in. The tab strip's fallback is interesting: it is not a skeleton at all, it is the real CampaignTabs component with kind={null}, because the tabs are known from the URL and only their labels depend on data.

The other four have no fallback at all:

<Suspense> <DeletedNotice /> </Suspense> 

<Suspense> with no fallback renders nothing until the child is ready, and all four children are client components that read useSearchParams. In the App Router, reading the search params in a client component opts the page out of static rendering unless it sits inside a boundary. One of these four is on the public home page, which is prerendered and served from a CDN: the notice that confirms an account was deleted reads ?deleted=1, and without that empty boundary a one-line confirmation message would make the whole marketing home page dynamic.

So the same API is doing two unrelated jobs in one codebase. With a fallback it is "show this while you wait". Without one it is "this subtree reads the URL, do not let it infect the page". Both are correct, both are three lines, and only one of them is about loading.

What the public half does instead

None of the above exists on the 76 pages in the sitemap. They have no loading.tsx, no skeleton and no spinner, because there is nothing to wait for: they are built at deploy time from content that lives in the repository, and the server sends finished HTML.

That asymmetry is worth stating plainly, because it is the actual reason the 885 lines are affordable. Loading states are a cost you pay per page that reads a database, and the 76 public URLs do not read one. Go and click around the guides or the pricing page and nothing ever fades in, because nothing was ever absent.

There is one more exception, and it is not static. The operator's panel at /admin is seven pages, every one of them reading the database, and it has zero loading.tsx files. Six hundred milliseconds of nothing on a route only I can reach never once annoyed me enough to write a skeleton for it, which is the most honest sentence in this post: 885 lines of loading state is not politeness, it is what you build for the pages other people wait on.

The numbers, in one place

Thing Count
Pages behind the login 19
loading.tsx files 19
Lines of loading UI 885
<Bone> uses 159
<FieldBone> uses 15
fade() call sites 7
Files with role="status" 12
loading.tsx files in /admin 0
shadcn Skeleton imports 1
Explicit Suspense boundaries 6
Of those, with no fallback 4

The thing I did not expect when I counted was the last two rows. I think of Suspense as the loading API and loading.tsx as sugar over it, and in this codebase it is the other way round: the route convention does all of the loading, and two thirds of the explicit boundaries are there to keep a page static.