Lazy Loading with next/dynamic

next/dynamic wraps React.lazy and <Suspense> so an import becomes its own chunk, fetched when the component first renders. It pays off only when the component does not render on first paint. Three versions of one panel show it: /slow imports the highlighter statically, /eager with next/dynamic but renders it at once, and /fast puts it behind a button.

app/_components/code-panel-lazy.tsxTSX
'use client'
import dynamic from 'next/dynamic'
import { useState } from 'react'
const Highlighter = dynamic(() => import('./highlighter'), {
  ssr: false,
  loading: () => <p>Loading highlighter...</p>,
})
export default function CodePanelLazy() {
  const [open, setOpen] = useState(false)
  return (
    <div>
      <button onClick={() => setOpen(true)}>Show highlighted source</button>
      {open && <Highlighter />}
    </div>
  )
}
The same 685 KB dependency in three arrangements
Route Import JS on first load LCP
/slow static 445,435 B 1.87 s
/eager dynamic, rendered 446,223 B 3.06 s
/fast dynamic, on click 143,620 B 0.82 s

/eager is the trap: it downloads everything /slow does and posts a worse LCP, because the chunk is discovered only after hydration, so the browser opens a second request when it could have been finishing the first. Clicking the button on /fast raises the total to 452,234 bytes - the same code, paid for by the readers who asked for it.

ssr: false skips server rendering, required for anything that touches window and allowed only inside a Client Component. loading supplies the fallback shown while the chunk downloads; give it the height of the real component, so lazy loading does not create the shift you were trying to avoid. A named export needs unwrapping: dynamic(() => import('./chart').then((m) => m.Chart)).

A heavier lever is to delete the dependency: highlighting only produces markup, so it can run in a Server Component with shiki and ship no JavaScript at all. Splitting a Server Component this way achieves nothing, because its code never reaches the browser.