Skip to main content

November 3, 2026

A branch that never renders still ships

App Router bundles follow static imports, not branches. Draft Sanity overlays and payment icons cost every shopper until imports matched conditions.

Sander Korf4 min read

An online fashion store runs on the Next.js App Router with Sanity as the content system. Shoppers browse products and check out on the published site. Editors open the same URLs in draft mode, a Next.js cookie mode that shows unpublished content, with Sanity's visual editing overlay (the VisualEditing component from next-sanity/visual-editing that draws click-to-edit outlines on the page). I found extra JavaScript on every cart visit while auditing the storefront client bundle. Nothing looked broken. Pages just downloaded modules no shopper ever executed.

The client bundle, the JavaScript the browser pulls down, is decided at build time from the module graph: whatever client code imports, statically, not what your if branch runs at runtime. In the App Router, server components render on the server and send little or no JS. Anything a client component imports ships to the browser even when a condition hides the subtree.

Pick Shopper, leave Static import + condition on, and watch the visual editing overlay stay in the download list although nothing on screen uses it. Switch to Loader under the condition and it drops out. Switch the visitor to Editor in draft mode and it comes back. Flip Icon names to Server-rendered markup and watch the payment icon map leave the cart list.

Simulation

Conditional imports and what still ships

Simulated client module graph for a storefront cart visit. Byte labels are illustrative except the measured icon map row.

Modules this visitor downloads

  • Locale layout (client boundary)
    example size 18 KBIn use
  • Cart summary (client)
    example size 22 KBIn use
  • Draft providers bundle
    example size 41 KBNever used on screen
  • Visual editing overlay stack
    example size 52 KBNever used on screen
  • Payment icon name map (generated)
    about 36 KB compressed (measured)In use

Highlighted modules ship but this visitor never runs them: Draft providers bundle; Visual editing overlay stack.

Visitor
Overlay import style
Cart payment icons

The layout branch that still pulled in VisualEditing

The locale layout rendered {isDraftMode ? <DraftSanityProviders /> : null} with a static top-level import of the draft providers module. That module statically imported the overlay stack. Draft mode never ran for shoppers, but the import was unconditional, so the overlay libraries sat in the published layout client graph.

The fix splits loading from rendering. The layout keeps a server-side async loader inside Suspense, only when draft mode is on:

{isDraftMode ? (
  <Suspense fallback={null}>
    <DraftProvidersLoader />
  </Suspense>
) : null}
 
export async function DraftProvidersLoader() {
  const { DraftProviders } = await import('./DraftProviders')
  return <DraftProviders />
}

Inside draft providers, the overlay itself uses next/dynamic with ssr: false instead of a static import from next-sanity/visual-editing. That is acceptable here because the overlay adds editor chrome, not page content shoppers or crawlers need. For real route bodies, ssr: false can starve Instant Nav shells; see Instant Nav needs an SSR shell, not ssr false.

Icon names dragged the whole generated map

The cart summary was a client component taking paymentMethods: readonly PaymentIconName[]. Turning a name into SVG on the client required the full generated name-to-SVG map, so the entire map shipped with the cart bootstrap even when checkout showed four icons. Moving the icon map out of that client graph removed about 36 KB compressed from the cart bootstrap, measured in the change.

The server now resolves icons and passes small markup objects: paymentIcons: readonly PaymentIconMarkup[] with a viewBox and trusted SVG html from the repo's generated icons on the server. The client renders what it was given via PaymentIconMarkupSvg. The name-based PaymentIcon remains for callers that are not on the cart bootstrap, such as the footer. The HTML is trusted because it never comes from user or CMS input; without that guarantee, injecting markup would be a smell.

When this does not apply

Skip the split when the module is tiny and the extra request costs more than the bytes saved, when most visitors need the code soon anyway and prefetch is the better lever, or when the icon set is small enough that one shared sprite is simpler. Never pass server-generated markup for icons that could originate from user or CMS input.

The reusable rule: code you only need under a runtime condition must be imported under that condition too, and a client component should receive rendered data, not keys into a lookup table it would have to ship.

Happy coding!

Sander