Code splitting with dynamic imports in Next.js

How to speed up your Next.js app with code splitting and smart loading strategies.

Milica Mihajlija
Milica Mihajlija

What will you learn?

This post explains different types of code splitting and how to use dynamic imports to speed up your Next.js apps.

Route-based and component-based code splitting

By default, Next.js splits your JavaScript into separate chunks for each route. When users load your application, Next.js only sends the code needed for the initial route. When users navigate around the application, they fetch the chunks associated with the other routes. Route-based code splitting minimizes the amount of script that needs to be parsed and compiled at once, which results in faster page load times.

While route-based code splitting is a good default, you can further optimize the loading process with code splitting on the component level. If you have large components in your app, it's a great idea to split them into separate chunks. That way, any large components that are not critical or only render on certain user interactions (like clicking a button) can be lazy-loaded.

Next.js supports dynamic import(), which allows you to import JavaScript modules (including React components) dynamically and load each import as a separate chunk. This gives you component-level code splitting and enables you to control resource loading so that users only download the code they need for the part of the site that they're viewing. In Next.js, these components are server-side rendered (SSR) by default.

Dynamic imports in action

This post includes several versions of a sample app that consists of a simple page with one button. When you click the button, you get to see a cute puppy. As you move through each version of the app, you'll see how dynamic imports are different from static imports and how to work with them.

In the first version of the app, the puppy lives in components/Puppy.js. To display the puppy on the page, the app imports the Puppy component in index.js with a static import statement:

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

To see how Next.js bundles the app, inspect the network trace in DevTools:

  1. To preview the site, press View App. Then press Fullscreen fullscreen.

  2. Press `Control+Shift+J` (or `Command+Option+J` on Mac) to open DevTools.

  3. Click the Network tab.

  4. Select the Disable cache checkbox.

  5. Reload the page.

When you load the page, all the necessary code, including the Puppy.js component, is bundled in index.js:

DevTools Network tab showing showing six JavaScript files: index.js, app.js, webpack.js, main.js, 0.js and the dll (dynamic-link library) file.

When you press the Click me button, only the request for the puppy JPEG is added to the Network tab:

DevTools Network tab after the button click, showing the same six JavaScript files and one image.

The downside of this approach is that even if users don't click the button to see the puppy, they have to load the Puppy component because it's included in index.js. In this little example that's not a big deal, but in real-world applications it's often a huge improvement to load large components only when necessary.

Now check out a second version of the app, in which the static import is replaced with a dynamic import. Next.js includes next/dynamic, which makes it possible to use dynamic imports for any components in Next:

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

// ...

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

Follow the steps from the first example to inspect the network trace.

When you first load the app, only index.js is downloaded. This time it's 0.5 KB smaller (it went down from 37.9 KB to 37.4 KB) because it doesn't include the code for the Puppy component:

DevTools Network showing the same six JavaScript files, except index.js is now 0.5 KB smaller.

The Puppy component is now in a separate chunk, 1.js, which is loaded only when you press the button:

DevTools Network tab after the button click, showing the additional 1.js file and the image added to the bottom of the file list.

In real-world applications, components are often much larger, and lazy loading them can trim your initial JavaScript payload by hundreds of kilobytes.

Dynamic imports with custom loading indicator

When you lazy-load resources, it's good practice to provide a loading indicator in case there are any delays. In Next.js, you can do that by providing an additional argument to the dynamic() function:

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

To see the loading indictor in action, simulate slow network connection in DevTools:

  1. To preview the site, press View App. Then press Fullscreen fullscreen.

  2. Press `Control+Shift+J` (or `Command+Option+J` on Mac) to open DevTools.

  3. Click the Network tab.

  4. Select the Disable cache checkbox.

  5. In the Throttling drop-down list, select Fast 3G.

  6. Press the Click me button.

Now when you click the button it takes a while to load the component and the app displays the "Loading…" message in the meantime.

A dark screen with the text

Dynamic imports without SSR

If you need to render a component only on the client side (for example, a chat widget) you can do that by setting the ssr option to false:

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

Conclusion

With support for dynamic imports, Next.js gives you component-level code splitting, which can minimize your JavaScript payloads and improve application load time. All components are server-side rendered by default and you can disable this option whenever necessary.