9 May 2025

How to Use Code Splitting to Reduce Initial Load Times in Next.js

Blazity

Performance Optimization

This article covers best practices for effective code splitting, pinpoint areas where it can be most beneficial, and explains how to implement it in Next.js.

Blazity team
Blazity team
<h1>How to Use Code Splitting to Reduce Initial Load Times in Next.js</h1>

The Expert Guide to Next.js Performance Optimization

This article is part of The Expert Guide to Next.js Performance Optimization, a comprehensive Ebook you can download for free here.


The guide covers every aspect of building fast, scalable web apps with Next.js, including:


- Code Splitting

- Streaming, Suspense & Hydration

- Next/Image Component

- Third-Party Scripts

- Font Optimization

- Rendering Strategies

- Core Web Vitals

- Infrastructure

- Real-life Examples

- How to Measure Web Performance

- Bundle Analysis.

The Expert Guide to Next.js Performance Optimization

This article covers best practices for effective code splitting, pinpoints areas where it can be most beneficial, and explains how to implement it in Next.js.


Understanding these potential issues helps developers and stakeholders understand the role of proper code splitting in building user-friendly web apps.

What Is Code Splitting?

Code splitting is a technique for optimizing web applications. It involves breaking down the codebase into smaller, manageable chunks loaded separately.

This approach usually improves initial load times and overall performance. In Next.js, code splitting works closely with dynamic imports, allowing developers to load components or modules on demand.


How do Next.js dynamic imports differ from static imports?

Dynamic imports differ from static imports in how they load JavaScript modules. With static imports, the build process includes modules at build-time, which can result in larger initial bundles. Whereas dynamic imports use code splitting to break up the application into smaller, on-demand chunks loaded only when needed.


This approach improves page load times by initially sending less code to the client, reducing the initial download size, and allowing faster page rendering.


Here are example strategies for segmenting a web application's code through code splitting:


  • Pages: Separate bundles for different pages or routes. Next.js enables this optimization by default. 
  • Components: Individual or groups of components as separate chunks.
  • Libraries: Distinct chunks for third-party libraries.
  • Features: Code related to specific features, possibly toggled by feature flags.
  • User Interactions: Modules loaded based on specific user actions (e.g., when scrolling into view).
next js dynamic import

Implementing these strategies requires planning the application's architecture to ensure modules are split logically and dependencies are managed properly. Modern developer tooling can automate a lot, but we still need to consider what code should be split and what should not.


Ignoring Code Splitting Risks:

  • Slower Load Times & Poor UX: Without code splitting, users must download the entire app upfront, leading to long load times and sluggish interactions.
  • Higher Bounce Rates & SEO Impact: Slow pages increase abandonment, and poor performance metrics (e.g., TBT, INP) hurt search rankings.
  • Security Risks: Including sensitive content in the main bundle instead of lazy loading can expose data to unauthorized users.
next js bundle

How to Implement Code Splitting?

Once you see the benefits of code splitting, the next question is how to apply it in practice. Next.js automatically splits code by route segments, making navigation faster. This mechanism contrasts with traditional React SPAs, where the entire app loads at once, which can slow down the initial experience.


However, this basic setup might not always meet all performance needs. Luckily, Next.js provides extra tools and features to improve code splitting further. Let's explore them.

next/dynamic (Pages Router)

The next/dynamic function in Next.js allows components to be loaded dynamically, reducing the initial load time by excluding them from the main JavaScript bundle. This function is handy for components with large dependencies or those not immediately needed.


Below is a basic example of how to use next/dynamic:

Code

1import dynamic from 'next/dynamic';
2import { useState } from 'react';
3
4const Modal = dynamic(() => import('../components/Modal'));
5
6export const GenericComponent = () => {
7  const [isModalOpen, setIsModalOpen] = useState(false);
8
9  return (
10    <div>
11      <h1>Welcome to My Next.js App</h1>
12      <button onClick={() => setIsModalOpen(true)}>Open Modal</button>
13      {isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
14    </div>
15  );
16};
17

In this example, the Modal component represents content that isn't needed when the application first loads. When you use next/dynamic function, your application loads the component only when required, preventing it from affecting the initial load time. This approach improves performance, especially for non-essential UI elements.


Note: Using next/dynamic incorrectly can hurt Core Web Vitals like CLS and LCP. Avoid dynamic imports for above-the-fold elements and small, independent components, as they should load immediately for a smooth user experience.


As a rule of thumb, it’s usually a good practice to dynamically import conditional UI elements such as: 


  • Heavy Components - Large third-party libraries (e.g., charts, maps, editors).
  • Rarely Used Components - Modals, dialogs, or tooltips that aren’t always visible.
  • Device-Specific Components - UI that differs between mobile and desktop.
  • Auth-Based Components - UI elements that depend on user roles or authentication status.
  • Locale-Specific UI - Different layouts or text-heavy components based on user language.


Technically speaking, dynamic imports are most effective for components large enough to impact performance, such as those involving complex data fetching, processing, or rendering. In contrast, they offer little benefit for small, static UI elements made up of just a few lines of code.


Example 1: Dynamic tab content

This example demonstrates a dynamic approach to managing multiple tabs. Code splitting allows each tab component to be loaded on-demand when clicking the corresponding tab button. Simply put, there's no need to import Tab2 and Tab3 if we're viewing only Tab1.

Code

1import { useState } from 'react';
2import dynamic from 'next/dynamic';
3
4const DynamicTabs = {
5  Tab1: dynamic(() => import('./Tab1')),
6  Tab2: dynamic(() => import('./Tab2')),
7  Tab3: dynamic(() => import('./Tab3'))
8};
9
10type TabKeys = keyof typeof DynamicTabs;
11
12export const HomePage = () => {
13  const [activeTab, setActiveTab] = useState<TabKeys>('Tab1');
14
15  const TabContent = DynamicTabs[activeTab];
16
17  return (
18    <div>
19      <h1>Dynamic Tab Switcher</h1>
20      <button onClick={() => setActiveTab('Tab1')}>Tab 1</button>
21      <button onClick={() => setActiveTab('Tab2')}>Tab 2</button>
22      <button onClick={() => setActiveTab('Tab3')}>Tab 3</button>
23      <div>
24        <TabContent />
25      </div>
26    </div>
27  );
28};
29

Example 2: Disabling SSR

To load a component only on the client side without using server-side rendering, we can disable pre-rendering by setting the ssr option to false. This setup will not pre-render the component, so unless you provide a placeholder, that component will be replaced by something else and loaded only on the client.

Code

1export const HeavyComponent = dynamic(() => import('../components/header'), {
2  ssr: false,
3  loading: () => <div>Loading…</div>
4});
5

Note: When your application renders a component conditionally, it won’t perform SSR even if you set the ssr option to true.Instead, the component will load only when the conditions are met on the client side. 


Why might you choose to disable server-side rendering for a component?


  1. Using Browser-Specific APIs (e.g., window, document) that aren't available during SSR.
  2. Client-Side Only Libraries that may break during SSR (e.g., certain charting libraries).
  3. Conditional Client-Side Rendering based on local state (e.g., feature flags in localStorage).
  4. User-Specific Content that doesn't need pre-rendering (e.g., dates adjusted to a user’s timezone).


There's also an option to add a loading fallback. In this case, the suspense will render a fallback first, so instead of a layout shift, the end user will see the placeholder while the component loads.

next/dynamic (App Router)

Next.js 13 introduced support for React Server Components, which are automatically code-split by default. This support eliminates the need to manage code splitting for server components manually.

However, code splitting is still relevant when working with client components, making it an important consideration. In this case, the approach to code splitting remains almost the same as in the Pages directory.


Let's break down an example:

Code

1'use client'
2
3import dynamic from 'next/dynamic'
4
5const TrueClientComponent = dynamic(() => import('../components/TrueClientComponent'), { ssr: false })
6
7export const ClientComponentExample = () => {
8  return (
9    <div>
10      <TrueClientComponent />
11    </div>
12  )
13}
14

You might notice something interesting: why would we disable server-side rendering (SSR) for a client component? The explanation is straightforward. By default, both “use client” and server components are initially rendered on the server. By setting ssr: false, TrueClientComponent will not be pre-rendered on the server and will only execute on the client side, providing an actual true “client” component experience.


Let’s return to server components because we can also load them dynamically. However, it's important to understand the implications:

Code

1import dynamic from 'next/dynamic' const ServerComponent = dynamic(() => import('../components/ServerComponent')) export const ServerComponentExample = () => { return (
2<div>
3  <ServerComponent />
4</div>
5) }
6

When a server component is dynamically imported, it primarily faciliates the lazy loading of any nested client components rather than directly optimizing the server-side loading of its content.

Automatic Code Splitting

In Next.js, each page is loaded as a separate chunk, enabling route-based code splitting with key benefits:


  • Isolated Error Handling: Errors on one page don’t affect others, enhancing stability.
  • Optimized Bundle Size: Users load only the code needed for visited pages, keeping performance fast as the app grows.
  • Prefetching: Next.js preloads linked pages in the background, ensuring near-instant navigation.


Next.js doesn’t support disabling code splitting, but you can achieve it by directly modifying the webpack configuration. 


Note: It's worth noting that it works the same way in the app directory. Shared code is bundled separately and reused across pages, preventing duplicate downloads.

External Modules

In modern web development, external modules or libraries enhance functionality without requiring you to build everything from scratch. However, these modules can significantly increase your JavaScript bundle size, impacting your performance. In Next.js, dynamic imports help mitigate this issue by loading external modules only when needed, as we know how to implement it for components. Let’s see how to implement it for modules.


Tip: It’s worth checking the bundle size of the packages you import to the project. You can find many popular websites for that purpose (e.g., bundlephobia, pkg-size) or IDE extensions (e.g., for VSCode import cost).


Why Use Dynamic Imports for External Functions?


  • Improved Initial Load Time: Only load external modules when required, reducing the initial load time. This approach is crucial for large libraries or features used conditionally.
  • Optimized Resource Usage: Download large modules only if the user interacts with the relevant feature, saving bandwidth and improving user experience.
  • Scalability: As your application expands, dynamic imports prevent performance degradation by limiting the modules loaded at startup.


Here's an example using code splitting to load a heavy library:


Code

1import { useState, useRef } from 'react'
2
3export const PdfComponent = () => {
4  const [pdfReady, setPdfReady] = useState(false)
5  const pdfLibRef = useRef(null)
6
7  const handleInputFocus = async () => {
8    if (!pdfLibRef.current) {
9      const jsPdfModule = await import('jspdf')
10      pdfLibRef.current = jsPdfModule.default
11    }
12  }
13
14  const handleGeneratePdf = async (e) => {
15    e.preventDefault()
16    
17    if (!pdfLibRef.current) {
18      const jsPdfModule = await import('jspdf')
19      pdfLibRef.current = jsPdfModule.default
20    }
21    
22    /* Actual PDF generation logic */
23    
24    setPdfReady(true)
25  }
26
27  return (
28    <div>
29      <form onSubmit={handleGeneratePdf}>
30        <input
31          type="text"
32          placeholder="Enter document title"
33          onFocus={handleInputFocus}
34        />
35        <button type="submit">Generate PDF</button>
36      </form>
37      
38      {pdfReady && <div>PDF Generated Successfully!</div>}
39    </div>
40  )
41}

This example uses the external jsPDF library, which is a perfect candidate for dynamic imports due to its size. The implementation includes two key performance optimizations:


  1. Preloading on Focus: The library starts loading when the user focuses on the input field before clicking the generate button.
  2. Module Caching with useRef: We store the imported module in a ref to ensure it's only loaded once, even across multiple component renders.


This pattern improves user experience and performance by avoiding the initial bundle size penalty and preventing input lag by deliberately timing when to load the library.


Keypoints

  1. We can use dynamic imports and next/dynamic to load code only when needed, reducing initial bundle size compared to static imports.
  2. Next.js automatically splits pages into separate chunks, enabling prefetching and faster load times.
  3. Disabling server-side rendering ensures that a component loads only on the client.
  4. Large conditional UI elements like tabs, modals, and carousels benefit from dynamic imports.
  5. Dynamically imported server components dynamically import nested client components but always load server-side.
  6. Code splitting separates pages and components into small chunks, reducing bundle size.
blazity comet

Get a quote

Empower your vision with us today
Brian Grafola, CTO at Vibes

“We are very happy with the outcomes and look forward to continuing to work with them on larger initiatives.”

Brian Grafola

CTO at Vibes

Trusted by
Solana logoVibes logoArthur logoHygraph logo
The contact information is only used to process your request. By clicking send, you agree to allow us to store information in order to process your request.