Skip to main content

Command Palette

Search for a command to run...

Before You Add Another Dynamic Import in Next.js, Read This

Updated
3 min readView as Markdown
Before You Add Another Dynamic Import in Next.js, Read This

I remember using dynamic imports for the first time, long back. This was the Pages Router era, with no Server/Client Component split, no RSC module graphs. One fine evening, I was optimising the most-visited page of our company's product, which was notoriously slow. I tried everything, ranging from lazy loading below-the-fold components, using content-visibility: auto, to using Intersection Observer for particularly heavy sections and a lot more tricks. But they only had a marginal impact on the performance score.

And then I stumbled upon an article mentioning how we can defer loading an entire module initially. At first, it felt similar to deferring a script, but not quite. I tried it out for a few components on the above-mentioned page and boom - a straight 25% reduction in first-load JS and a substantial decrease in the page's bundle size, as observed with the next bundle analyzer. It felt almost magical after the struggle to squeeze out minuscule performance gains.

A few days ago, I was optimizing an ad landing page and noticed the next/dynamic calls scattered across the component tree. I recalled the 'eureka' moment described earlier. And I was so sure of my assumption as to how a dynamic import works. The route already had export const dynamic = 'force-static' set, so I started questioning whether those dynamic imports were earning their place.

I converted a few components from next/dynamic back to static imports. The result: a marginal bump in performance score, and a more interesting drop in Cumulative Layout Shift (CLS). It contradicted my mental model.

That sent me digging into how Next.js actually handles dynamic imports.

The mental model most of us default to: dynamic import → smaller initial bundle → better performance. But it is incomplete in a manner that isn't visible until you actually encounter the issue.

next/dynamic is a combination of React.lazy() and Suspense. In a client component, it splits the imported component into a separate chunk, deferred until needed. Code-splitting 101.

But server components are a little tricky. The RSC compiler maintains separate server and client module graphs, and Next.js's own docs say that when a server component dynamically imports a client component, automatic code splitting isn't supported. So "dynamic import → smaller bundle" can simply not hold, depending on where the import sits in the tree.

And if the component is needed immediately anyway, there's a cost either way. With ssr: false or a loading fallback, you get a blank or placeholder state on first paint, then the real content popping in once the chunk loads. That's my guess for the source of the CLS I was seeing. I kept it simple and used static imports, which put the components in the initial server-rendered HTML from the start, no pop-in required.

This tells me that dynamic imports aren't a performance optimization by themselves. They're a loading strategy and a way to sequence when and where code loads, not a guarantee that less code ships or renders faster.

Another example is a heavy modal component which has a multi-step signup form that opens after clicking on a CTA button. You have two choices: defer loading the Signup modal until interaction (might increase the INP), or bundle it upfront.

Still narrowing down the exact mechanism behind the CLS improvement. If you've debugged something similar with next/dynamic in the App Router, I'd like to hear what you found.

Peace out :)