A fashion storefront runs live shopping on a dedicated liveshow route with Next.js Instant Navigations. Shoppers soft-navigate from the homepage or a campaign link into an active show. Playwright and accessibility checks expect a real document shell: a <main> landmark, headings, and enough HTML that the page is not a blank client island. After we wired the Bambuser player through next/dynamic with { ssr: false }, soft navigations painted empty chrome. Hard refresh looked fine. Instant Nav did not.
Instant Navigations prerender and lock a static shell for eligible routes. That shell is what the browser paints on the first frame of a client transition. If the server sends only a loading placeholder or nothing at all, the locked snapshot is useless. I had also awaited a cookie-backed cart country in the same React Server Component tree as the shell. That await ran before the cached frame could serialize, so the static shell never carried the wrapper markup tests were hunting.
Why ssr false starves the locked shell
next/dynamic(..., { ssr: false }) tells Next.js to skip server rendering for that subtree. On a full document load you still get surrounding layout from the parent. On Instant Nav the transition reuses the prerendered shell for the route. When the dynamic import is the liveshow page body, the shell HTML for that segment is literally empty until JavaScript hydrates. End-to-end tests that assert on <main> or landmark roles fail even though production users eventually see the player.
I tried leaving ssr false and wrapping the route in Suspense with a skeleton. The skeleton rendered on hard refresh. Soft nav still locked an empty or loading-only snapshot because the dynamic boundary never participated in the static shell pass. Instant Nav is not a free pass to defer everything to the client.
SSR the wrapper, mount Bambuser in the client
The split that worked: server-render a thin client wrapper component that owns layout landmarks and props wiring. Keep the actual Bambuser embed initialization inside useEffect so vendor SDK code still runs only in the browser. The wrapper lands in the prerendered HTML. The player attaches after hydration without blocking the shell.
// Must SSR the wrapper. Instant Nav locks the static shell.
export function DynamicLiveshowClient(props: LiveshowClientProps) {
return <BambuserLiveshowClient {...props} />;
}Inside BambuserLiveshowClient, effects load the show id, cart hooks, and player script. The DOM already exposes <main> and test ids before those effects run.
Do not await cookies on the shell path
Cart country for the player came from an HTTP-only cookie. Awaiting that read at the top of the page component blocked the entire RSC payload for the liveshow route, including the part Instant Nav caches. I stopped awaiting on the shell path. The page passes a cartCountryCodePromise into the client wrapper and resolves country with use() inside the subtree that actually needs it. The player stays mounted while country resolves; the outer shell no longer waits on session IO.
<DynamicLiveshowClient
showId={showId}
cartCountryCodePromise={loadCartCountryCode().catch(() => fallbackCountry)}
/>If country fails, the catch returns a safe default so the promise still settles and Suspense boundaries do not hang forever.
Soft navigations now paint landmarks immediately. Bambuser still boots client-side. The lesson I keep reusing: Instant Nav routes need SSR-visible structure for anything that must exist on frame one, and session reads belong on branches that can suspend without holding the whole static shell hostage.
Happy coding! Sander