Doing skeletons well in Next.js

A practical fix for janky tab navigation: route-level skeletons, Link prefetching, and caching the work that does not need to happen after the click.

James DawsonJames Dawson
7 min read
Doing skeletons well in Next.js

The context

I was looking at a polished AI app recently. Good design, good animations, clearly a serious product. But one thing kept annoying me: the tab navigation felt heavy.

Janknoun/dʒaŋk/

Sluggishness in a user interface, usually caused by work that blocks rendering or makes the browser do too much at the wrong time.

Jank can come from a lot of places: slow data fetching, oversized JavaScript, expensive renders, unnecessary rerenders, heavy DOM, or background work fighting the main thread.

This case was simpler. The tabs looked instant, but they were not instant. Each click had a tiny pause. Not enough to break the product, but enough to make the whole app feel heavier than it should.

The app was not broken. It just did not feel as good as it looked.

The audit

The problem was a standard tabbed section: click a tab, render a new view. I recreated the basic version here.

The delay is small, but noticeable. That is what makes it frustrating. The user is already inside the app. They are not expecting a full page load. They are clicking between nearby views, so the transition should feel close to instant.

Soft navigationnoun/sɒft nævɪˈɡeɪʃən/

A navigation where the view changes without the browser loading a completely new document.

In the Next.js App Router, shared layouts can stay mounted while only the changed route segment is fetched and rendered. That is powerful, but it also raises the bar. If a soft navigation hesitates, the user feels it.

Give the navigation a floor: loading.tsx + <Link>

The first fix was adding loading.tsx to each tab route.

loading.tsx gives a route segment an immediate fallback while the next UI is loading. In this case, the fallback was a skeleton shaped like the destination tab. Before that, clicking a tab created a visual gap: the old content disappeared, the new content was not ready, and the app felt like it paused.

The second fix was using <Link> instead of router.push() for the tabs.

router.push() is useful for programmatic navigation: after a form submit, an auth redirect, a wizard step, or some action that decides where to go next. But for visible navigation, <Link> is the better default. It gives you real anchor semantics, client-side navigation, and automatic prefetching in production.

That matters for tabs. The destinations are already visible. The user is probably going to click one. So let Next start warming the route before the click happens.

The important detail is how this works for dynamic routes. Static routes can be prefetched fully. Dynamic routes may be skipped, or only partially prefetched if a loading.tsx boundary exists. In that case, Next can prefetch the shared layout and fallback UI, then swap in the real content once the server response is ready.

So loading.tsx gives the navigation a floor, and <Link> helps Next reach that floor before the user clicks.

One gotcha: test this in production. Prefetching behaves differently in development.

Now the click has an immediate response. The skeleton appears straight away, and because it matches the final layout, the page does not jump when the real content arrives.

Loading...

Each tab gets its own loading.tsx, nested inside the segment it belongs to:

app/analytics/
Loading...

That matters because each tab may have a different shape. A chart dashboard, an activity feed, and a settings form should not all use the same generic skeleton. That is how you get layout shift dressed up as loading UI.

Put the fallback where the shape is known.

Cache the work that does not need to happen after the click

The next question was not “how do we make the skeleton better?” It was: “why are we waiting at all?”

Some data really does need request-time freshness. Some does not. In this case, the tab data was stable enough to cache, so it should not have been fetched from scratch on every click.

This is where the earlier fixes start to stack.

With a dynamic route, prefetching may only get you as far as the loading boundary. Once the expensive work is cached — whether that is the data, the component output, or the route result depending on your setup — there is much less left to do after the click.

Caching does not replace skeletons or prefetching. It makes them more effective. The skeleton is still there for the slow path, but on the happy path the content is already ready.

You will rarely cache everything, and you should not try. The demo below is the realistic end state. Overview and Activity hold stable data, so they are cached: prefetch warms the full content and the click lands on it instantly. Settings is different — it shows account-specific, personal data that has to be fetched per request, so it stays dynamic. Prefetch can only warm that route as far as its skeleton. Click Settings and you still get an immediate floor, then the real content a moment later.

That mix is the goal, not a compromise: cache what is stable so it is instant, and keep a skeleton where the data has to stay fresh.

The navigation checklist

When navigation feels slower than it should, I now check these things.

1. Is this a hard navigation or a soft navigation?

If the browser loads a new document, it is a hard navigation. If the shell stays mounted and only part of the view changes, it is a soft navigation. Users expect soft navigations to feel faster.

2. Are visible destinations using <Link>?

If the user can see the destination, it should usually be a <Link>. Use router.push() when the destination is decided by code after an action.

3. Does the destination have a useful loading.tsx?

A missing loading state creates a dead moment. A good loading.tsx gives the app an immediate visual response.

4. Does the skeleton match the final layout?

A skeleton should reserve the same space as the real UI. Grey boxes are not enough. The goal is stability.

5. Are we fetching data that could be cached?

Skeletons make waiting feel better. Caching removes the wait. If the data is not user-specific, request-specific, or genuinely fresh, it probably should not be fetched from scratch on every tab click.

6. Is expensive work starting only after the click?

Look for route fetches, client-side data fetching, blocking analytics, expensive renders, large chunks, unnecessary rerenders, and heavy components mounting all at once.

The useful question is: what has to happen after the click, and what could have happened before?

7. Did you test the production build?

Do not judge this in development. Build the app, test the real route, throttle the network if needed, and record the interaction. The user does not care which framework feature is helping. They care that the app feels immediate.

Final thoughts

The fix was not one magic trick. It was a stack:

  1. Add loading.tsx so the navigation has an immediate fallback.
  2. Use <Link> so visible destinations can be prefetched.
  3. Cache the work that does not need request-time freshness.

That is how you turn a slightly janky tab interaction into something that feels instant.

Tiny performance details matter because users feel them before they understand them. They may not know what an RSC payload is, what prefetching does, or whether a route is cached. But they know when an app feels heavy. And they know when it feels good.

performancenextjsfrontend