#Code splitting blocks

6 messages · Page 1 of 1 (latest)

torn moth
#

I'm creating this thread to be a shared discussion for anyone who stumbles upon this in the future. I would love to hear how other people are tackling this issue in their projects.

The Problem

We have a CMS-driven Next.js App Router site (Payload CMS) with 20+ block components. A RenderBlocks server component maps over page data and renders the appropriate block for each entry. The issue is that all client components referenced by the page end up in a single JS chunk, even if only a few blocks appear on any given page. This means every visitor downloads the client JS for every possible block.

torn moth
#

What We Tried That Didn't Work

  1. next/dynamic in the server component — Using dynamic(() => import(...)) in RenderBlocks.tsx (a server component) does NOT code split. All client components still end up in one chunk.

  2. Webpack splitChunks config — Can force separate chunk files, but the page runtime still eagerly loads all of them. The problem is runtime loading, not file splitting.

  3. Turbopack — No equivalent configuration available. Handles chunking automatically with no exposed knobs.

Why This Happens

This is a confirmed limitation in Next.js. dynamic() / next/dynamic only performs actual lazy code splitting inside 'use client' files. When a server component imports client components — even via dynamic() — they are all treated as synchronous dependencies of the page's client manifest and bundled into one chunk.

Tracked Issues

  • #54935 — "Server side dynamic imports will not split client modules in multiple chunks" (Sep 2023)
  • #61066 — "Dynamic Import of Client Component from Server Component Not Code Split" (Jan 2024, 28+ thumbs up, labeled linear: next)

Official Docs Acknowledge It

The Next.js lazy loading docs now state: "When a Server Component dynamically imports a Client Component, automatic code splitting is currently not supported." The word "currently" implies intent to fix, but it has been 2+ years with no resolution.

#

The Architecture We Settled On

To work around the limitation, we split RenderBlocks into two files:

  • RenderBlocks.server.tsx — server component that handles server-only blocks and data fetching
  • RenderBlocks.client.tsx'use client' component that imports all client blocks via dynamic(), achieving actual per-block code splitting

Three paths for creating a new block:

Path A: No client interactivity → Server Block

  • Create MyBlock/MyBlock.tsx (server component, async if it needs data)
  • Register in RenderBlocks.server.tsx

Path B: Client interactivity, no server data → Client-Only Block

  • Create MyBlock/MyBlock.client.tsx with 'use client'
  • Register via dynamic(() => import(...)) in RenderBlocks.client.tsx

Path C: Client interactivity + server data → Client Block + Server Data

  • Create MyBlock/getProps.ts (async function that fetches data)
  • Create MyBlock/MyBlock.client.tsx with 'use client' (receives data as props)
  • RenderBlocks.server.tsx calls getProps() and passes data down
  • RenderBlocks.client.tsx registers via dynamic(() => import(...))
#

Example:

blocks/
├── RenderBlocks.server.tsx
├── RenderBlocks.client.tsx
│
├── BannerBlock/       <- Server Block
│   └── BannerBlock.tsx
│
├── FaqBlock/          <- Server Block
│   └── FaqBlock.tsx
│
├── VideoBlock/        <- Client Block
│   └── VideoBlock.client.tsx
│
├── SubscriptionBlock/ <- Client Block + Server Data
│   ├── getProps.ts
│   └── SubscriptionBlock.client.tsx
#
// RenderBlocks.server.tsx

import RenderBlocksClient from './RenderBlocks.client'

const serverBlocks = {
  banner: BannerBlock,
  faq: FaqBlock,
}

// Only needed for client blocks that require extra server data
const clientGetProps = {
  subscription: getSubscriptionProps,
}

export default async function RenderBlocks({ blocks }) {
  return blocks.map(async (block) => {
    // Server block — render directly with CMS data
    const Server = serverBlocks[block.type]
    if (Server) return <Server {...block} />

    // Client block — always pass CMS data (block),
    // plus fetch extra server data if needed
    const getData = clientGetProps[block.type]
    const extra = getData ? await getData(block) : {}

    return <RenderBlocksClient block={block} extraProps={extra} />
    // block = CMS page data (title, description, productId, etc.)
    // extraProps = additional server-fetched data (Stripe plans, DB rows, etc.)
  })
}