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.
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
- Third-Party Scripts
- Font Optimization
- Rendering Strategies
- Core Web Vitals
- Infrastructure
- Real-life Examples
- How to Measure Web Performance
- Bundle Analysis.
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.
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:
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:
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.
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:
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?
window, document
) that aren't available during SSR.localStorage
).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.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:
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.
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?
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:
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
next/dynamic
to load code only when needed, reducing initial bundle size compared to static imports.Get a quote
“We are very happy with the outcomes and look forward to continuing to work with them on larger initiatives.”
Brian Grafola
CTO at Vibes