DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our campaign layout awaits one thing, the URL, and the header streams in behind it

Nakodo reads a brand's website, finds creators and businesses who fit it, and emails them. Inside the app, each campaign has three tabs over one shared shell: its results, its outreach and its brief. The shell is a Next.js layout, and layouts are the easiest place in an App Router project to make every page under them slow, because anything the layout awaits is awaited before any child page can render.

So the campaign layout awaits exactly one thing, and it is not data.

export default async function CampaignLayout({ children, params }: LayoutProps<"/campaigns/[id]">) {
  const { id } = await params;
  return (
    <div className="space-y-6">
      <div className="space-y-3">
        <Suspense fallback={<HeaderSkeleton />}>
          <CampaignHeader id={id} />
        </Suspense>
        <Suspense fallback={<CampaignTabs campaignId={id} kind={null} />}>
          <Tabs id={id} />
        </Suspense>
      </div>
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

That is the whole layout body. 129 lines in the file, and the only await at the top level is params. The campaign's name, status, catalogue number, keyword progress and action buttons all live inside CampaignHeader, which is an async component behind its own boundary. The comment above it says what the shape is for:

// Only the URL is awaited here. The header streams in on its own, so opening a
// campaign or switching tabs shows that page's skeleton straight away.
Enter fullscreen mode Exit fullscreen mode

One of the two fallbacks is the real component

The header's fallback is a skeleton, which is ordinary. The tab bar's fallback is more interesting: it is CampaignTabs itself, with kind={null}.

The tab bar needs the campaign's kind because one of its three labels is not fixed. A creator campaign's first tab is called Creators and a business campaign's is called Businesses, which comes out of kindWords(kind). Everything else about the row, the two other labels, the URLs, the underline, the active state read from the pathname, needs no data at all. So the component takes kind: CampaignKind | null and draws a placeholder in that one slot:

// `kind` is null while the campaign loads: the results tab's name waits for it.
const results = kind ? (
  kindWords(kind).Many
) : (
  <>
    <span className="hatch inline-block h-3 w-16 rounded-sm align-middle" aria-hidden="true" />
    <span className="sr-only">Results</span>
  </>
);
Enter fullscreen mode Exit fullscreen mode

Two things follow from that. The first is that the tabs are clickable before the campaign has loaded, because the hrefs are built from the id in the URL. The second is that a screen reader never hears a blank: the hatched block is aria-hidden, and an sr-only span says "Results" in the one state where the real word is unknown. hatch is our house pattern for "not yet observed", which is the same idea the skeletons use.

A fallback that is the same component in a degraded state costs one nullable prop and saves a layout shift, because the row that arrives is the row that was already there.

Three awaits, one query

CampaignHeader and Tabs both start with the same line:

const { user, campaign } = await requireCampaign(id);
Enter fullscreen mode Exit fullscreen mode

The child page awaits it again. That is three calls for one render, and one database query, because the authorisation helper is wrapped in React's cache():

export const requireCampaign = cache(async (campaignId: string) => { /* ... */ });
Enter fullscreen mode Exit fullscreen mode

cache() is per request, so each of the three callers gets the same promise. This is what makes the pattern practical: you can push the data fetch down into the component that needs it, instead of lifting it into the layout and passing it down, without paying for the fetch more than once. Both halves of the campaign header can each ask "whose campaign is this" as their first statement, and the answer is loaded once.

The skeleton is not silent

function HeaderSkeleton() {
  return (
    <div className="flex items-center justify-between gap-4" role="status">
      <span className="sr-only">Loading campaign</span>
      <div className="space-y-3 py-1">
        <Bone className="h-7 w-48" />
        {/* ... */}
      </div>
      <div className="flex gap-2" aria-hidden="true">
        <div className="h-9 w-24 rounded-md border" />
        <div className="size-9 rounded-md border" />
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

role="status" plus one sr-only sentence, and the decorative part hidden. The two empty bordered boxes on the right are the shapes of the Launch button and the overflow menu, so the buttons do not jump sideways when they arrive. There are 19 loading.tsx files in the app, one per page, and every one of them is built from the same Bone primitive and the same role="status" wrapper, so the shell and the page below it agree about what a loading state looks like.

A clicked tab marks itself before its page exists

Because the layout stays mounted while the child page loads, a tab click has a visible gap: the old page is still there, the new one is being rendered on the server. Next 16 exposes exactly the signal this needs.

function TabMark({ active }: { active: boolean }) {
  const { pending } = useLinkStatus();
  return (
    <span
      aria-hidden="true"
      className={cn(
        "absolute inset-x-0 bottom-0 h-0.5",
        active ? "bg-primary" : pending ? "bg-primary/40 motion-safe:animate-pulse" : "bg-transparent",
      )}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

useLinkStatus() only works inside a next/link, which is why the underline is a child of the Link rather than a sibling. A clicked tab gets a faint pulsing underline immediately, the destination page shows its own loading.tsx skeleton, and the header does not flicker because it was never unmounted.

Next's own documentation for the hook says to prefer route-level fallbacks and prefetching, and lists its uses as the cases where prefetching is disabled or still in progress, or where the destination has no loading.js. Each of these tabs has a loading.tsx, so the underline is not standing in for a missing skeleton. It covers the window before that skeleton, while the click has happened and the prefetch has not finished, which on a cold tab on a phone is the part that reads as nothing happening.

One detail that is easy to get wrong: the row is a horizontal scroller on narrow screens, and the underline is drawn by an inset shadow rather than a border, so the mark can sit over the rule without sticking out of the scroll box. A pixel outside an overflow-x-auto row makes overflow-y compute to auto, and then the row scrolls vertically too.

The one thing the header refuses to say

// The header alone: the campaign's name, how it's doing and its actions. Why a
// campaign isn't searching or sending (paused, enough leads waiting, a daily
// limit used up) stays out of it, the status beside the name says it, and each
// tab says it where it matters.
Enter fullscreen mode Exit fullscreen mode

The header computes plenty: whether any work is queued, whether the campaign is holding back because enough leads are already waiting, which pause mode is in force. All of that collapses into one status dot with a label. The reasons live on the tab where you can act on them, because a reason in a header is a reason you read on every tab and can do nothing about on any of them. Our public pricing page states the rules that produce most of those reasons, including that a plan's day is counted across all of your campaigns and that extra campaigns are paused rather than deleted when a plan gets smaller.

There is one side effect in the header, and it runs after the response:

after(() => fillLogos([campaign]));
Enter fullscreen mode Exit fullscreen mode

Finding a brand's logo is a fetch of someone else's site. It must not hold up the header, and it must not run on every render once the logo is stored, so it goes in Next's after() and the function it calls guards on the column still being null.

Six boundaries, two fallbacks

Grepping the whole app for <Suspense returns six boundaries: two in the signed-in layout, two here, one on the marketing home page and one in the client component that guesses whether a visitor is signed in. Four of the six have no fallback at all, because they exist to let a child read useSearchParams rather than to stream anything. Only the two in this file render a placeholder, which is a fair summary of where streaming actually earns something in this app: the screen you open from a link, where the data is yours and the shell is not.

You can watch the boundary without an account, in the sense that you can watch it refuse:

$ curl -sI https://nakodo.app/campaigns/11111111-1111-1111-1111-111111111111 | head -2
HTTP/2 307
location: /login?next=%2Fcampaigns%2F11111111-1111-1111-1111-111111111111
Enter fullscreen mode Exit fullscreen mode

A well-formed id and a malformed one both answer the same way, because the redirect happens in the proxy before any of this renders. The pages that describe what the three tabs are for are open to everyone: how it works walks the stages, and the guides cover the same ground from the outside.

Top comments (0)