Why component-level code splitting matters in Next.js

Next.js already splits JavaScript by route by default: on initial load, only the code for the current route is sent, and chunks for other routes are fetched during navigation. This reduces the amount of script that must be parsed and compiled up front.

Route-based splitting, however, treats every component on a route as part of that route's initial bundle. For large components that aren't visible immediately or that only appear after a user interaction, you can gain more by splitting at the component level. Next.js supports dynamic import(), which lets you load any JavaScript module—including React components—on demand and have each import emitted as its own chunk. In Next.js, components loaded this way are server-side rendered (SSR) by default, unless you opt out.

Static imports vs. dynamic imports

The difference becomes clear with a small sample app: a page with a single button that reveals a puppy image. In the static version, the Puppy component lives in components/Puppy.js and is imported in index.js with a standard import statement. Inspecting the network trace shows that all necessary code, including Puppy.js, is bundled into index.js at initial load. Clicking the button only triggers a request for the puppy JPEG itself.

The problem: users who never click the button still download the Puppy component code. For a tiny component that's negligible, but for real-world, large components it can add hundreds of kilobytes to the initial payload.

Replacing the static import with a dynamic one via next/dynamic changes the picture:

import Puppy from "../components/Puppy";
import dynamic from "next/dynamic";

// ...

const Puppy = dynamic(import("../components/Puppy"));

On first load, only index.js is downloaded, and it's about 0.5 KB smaller (37.4 KB vs. 37.9 KB) because the Puppy component is no longer part of that chunk. The puppy code now lives in a separate chunk (1.js) that is fetched only when the button is pressed.

Adding a loading indicator

Lazy-loaded resources can introduce visible delays, so it's a good practice to show something in the meantime. You can do this by passing a second argument to the dynamic() function:

const Puppy = dynamic(() => import("../components/Puppy"), {
  loading: () => <p>Loading...</p>
});

To see it work, simulate a slow connection, e.g., Fast 3G in DevTools, then press the button. The component takes longer to fetch, and the "Loading…" message displays until the chunk arrives.

Opting out of SSR

Some components only make sense on the client—a chat widget, for instance. To keep such a component out of the server-rendered output, set the ssr option to false:

const Puppy = dynamic(() => import("../components/Puppy"), {
  ssr: false,
});

Summary

Dynamic imports in Next.js unlock component-level code splitting on top of the built-in route-level splitting. The main benefits: smaller initial JavaScript bundles and the ability to defer larger components until they're actually needed. All dynamically imported components are server-side rendered by default, and the behavior can be disabled per component when necessary.