Instant Navigation

How the kit structures pages so navigations paint immediately, and the rules to follow when you add your own.

The kit runs with Next.js Cache Components enabled. Pages render what they already know straight away, and data streams into <Suspense> boundaries as it resolves. A navigation should never wait on a database round trip before painting.

This page covers the rules to follow when you add pages of your own. Break them and the dev overlay will tell you — the validator runs on every page load.

The core rule

Never await runtime data at the top of a page or layout.

Runtime data means params, searchParams, cookies(), headers(), or any data fetch. Awaiting it at the top blocks the whole page.

// Blocks: nothing renders until the query returns
async function MembersPage({ params }: PageProps<'/home/[account]/members'>) {
  const { account } = await params;
  const members = await loadMembers(account);

  return (
    <PageBody>
      <PageHeader title="Members" />
      <MembersTable members={members} />
    </PageBody>
  );
}

Keep the page synchronous, pass the promise down, and await it inside a child:

function MembersPage({ params }: PageProps<'/home/[account]/members'>) {
  return (
    <PageBody>
      <PageHeader title="Members" />

      <Suspense fallback={<CardSkeleton />}>
        <MembersTable params={params} />
      </Suspense>
    </PageBody>
  );
}

async function MembersTable({ params }: { params: Params }) {
  const { account } = await params;
  const members = await loadMembers(account);

  return <Table rows={members} />;
}

The header now paints immediately and only the table waits.

Put the boundary as low as possible

Split the component and check what actually reads the data before wrapping it. Usually less depends on it than you think.

The sidebar is the clearest example. It looks like it needs the workspace, so the tempting move is to wrap the whole thing — but then it is missing from the first paint and pushes the layout when it arrives, because a suspended region reserves no space.

In fact only the account dropdown reads the workspace. The frame and every navigation link come from config, so the boundary belongs inside the sidebar:

<Sidebar>                                 {/* renders immediately */}
  <SidebarHeader>
    <Suspense fallback={<SidebarHeaderSkeleton />}>
      <TeamAccountSidebarHeader account={account} />
    </Suspense>
  </SidebarHeader>

  <SidebarContent>
    <SidebarNavigation config={config} /> {/* renders immediately */}
  </SidebarContent>
</Sidebar>

Choosing a fallback

There are three answers, not two:

RegionFallback
Known shape — table, cards, page headerMatching skeleton
Unknown shape, large surface — CMS contentReserved space, no content
Fixed slot that resolves fast — a header widgetSmall skeleton, no delay

The kit's building blocks live in apps/web/components/skeletons.

Two rules behind the table:

  • A suspended region reserves no space. If the fallback does not occupy the same footprint as the real content, the page jumps when it resolves. Prefer moving the boundary inward over drawing a bigger fallback.
  • Only delay content fallbacks. Skeletons use a Delayed wrapper so navigations faster than 200ms paint nothing rather than flickering. A fixed slot that is always occupied has no flicker to avoid — delaying it just leaves a gap and then pops in.

For content whose shape you cannot predict — blog posts, docs — use ContentFallback. It holds the height and shows a spinner only if the wait is real. A grid of grey cards standing in for posts mispredicts badly.

Client components that read the URL

usePathname() and useSearchParams() are runtime data too. Split the component so only the URL-dependent part suspends:

// The link renders in the shell; only the active highlight waits.
export function NavItem({ path, children }: Props) {
  const pathname = usePathname();

  return <NavLink path={path} isActive={isRouteActive(path, pathname)}>{children}</NavLink>;
}

Then wrap NavItem in <Suspense> with NavLink (unhighlighted) as the fallback, so the navigation never disappears.

Caching data

use cache is available. Pair it with cacheLife:

async function getPosts() {
  'use cache';
  cacheLife('hours');

  return cms.getPosts();
}

Never put plain use cache on anything using the Supabase server client. That client marks its caller request-bound, so the cache entry would be wrong. Either extract the value you need and pass it in as an argument, or use use cache: private, which caches per session in the browser only.

Authentication

Auth gating lives in proxy.ts, not at the top of your page. The redirect happens before any HTML is sent, so unauthorised users never see a flash of the page and your page never blocks on the session.

Add a pattern there for new protected routes:

{
  pattern: new URLPattern({ pathname: '/your-route/*?' }),
  handler: requireAuthenticatedUser,
}

Keep the check in the page too as defence in depth — just behind a boundary.

Client queries need seeding

React Query is not hydrated automatically. A component gated on useUser() renders a spinner during SSR and the real content on the client, which shows up as an empty card and a hydration warning.

The authenticated layouts seed the cache with QueryHydration. If you add a client query that gates rendering, seed it the same way or pass initialData.

Opting out

Some routes should block — an admin area that must resolve permissions before rendering anything, for example. For those:

export const instant = false;

Use it sparingly. In the kit only /admin does.