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.

Never wrap a use cache function in React's cache(). They do not compose, and the build fails with a misleading error:

TypeError: Invalid value used as weak map key
at generateMetadata

use cache already dedupes, and across requests rather than only within one, so the wrapper is redundant as well as broken.

Prerendering content routes

Dynamic segments serving content — blog posts, docs pages — should enumerate their paths so they can be built ahead of time:

export async function generateStaticParams() {
'use cache';
cacheLife('hours');
const client = await createCmsClient();
const { items } = await client.getContentItems({ collection: 'posts' });
return items.map((item) => ({ slug: item.slug }));
}

It must return at least one param or the build fails. Paths you don't return are still served — Next renders the shell immediately and upgrades it in the background once the params are known.

Catch-all segments take an array: { slug: item.slug.split('/') }.

When to reach for connection()

You will hit this error:

Next.js encountered the unstable value `Date.now()` while prerendering.

<Suspense> does not fix it. Uncached data and unstable values are separate problems. A boundary lets Next stop and stream; an unstable value is an error, because the same render would produce a different result next time.

The heuristic:

The value isDo this
Genuinely per-request — a session, a nonce, "now" for this visitorawait connection() before it
The same for everyone for a while — a copyright year, a price listuse cache + cacheLife
Only meaningful in the browser — a relative timestamp, a random ID'use client'
Telemetryperformance.now()

Two rules that save time:

It must be awaited. During a prerender connection() returns a promise that never resolves — that is how it stalls. void connection() looks equivalent and does nothing at all.

It goes before the first line that touches the value, which is often earlier than you expect. Any auth call reads Date.now() to check session expiry, so requireUser, getUser and isSuperAdmin all trip it.

async function loadWorkspace() {
await connection(); // before, not after
const client = getSupabaseServerClient();
const user = await requireUserInServerComponent();
// ...
}

Finding the right place

The stack trace names the library, not your code — you will see GoTrueClient or dehydrate, and a component several levels up. Work outwards: find the first thing in that subtree that constructs a client or reads a clock, and put await connection() above it.

Prefer the shared loader over the page: the two workspace loaders and requireUserInServerComponent cover most authenticated routes, so three edits do most of the work. Pages and loaders that build a client themselves still need their own — find them with:

grep -rl "getSupabaseServerClient" apps/web/app --include="page.tsx" \
--include="layout.tsx" --include="*.loader.ts"

Render paths only. Server actions and route handlers always run at request time, so they never prerender.

Checking a route you just added

A route that blocks navigation is still a valid route. next build passes, nothing throws, and the page works — it just is not instant. So the build will never tell you, and neither will your tests.

The dev overlay does. After adding or changing a route, load it in the browser signed in as the role it needs, and check for:

Next.js encountered uncached data during prerendering or a navigation.

If you see it, something is awaited above every <Suspense> — usually the page itself being async. Keep the page synchronous, pass the promise down, and await it inside a boundary.

Two things worth knowing when you check:

  • Confirm you are actually on the route. If your session has expired you are looking at the sign-in page, which is always clean. That reads as a pass and is the easiest way to convince yourself an app is healthy when it is not.
  • export const instant = false does not silence this. It only stops flagging navigations into that segment; direct loads still report. An opted-out route showing the message is expected.

The dev server also exposes an MCP endpoint. get_routes lists every entry point — including ones a filesystem search misses — and get_compilation_issues checks them all without a browser.

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.