When building modern web applications, performance isn't a luxury — it's a necessity. Next.js comes with built-in code splitting to help you deliver faster, leaner apps by loading only what’s needed.
So how can you go beyond the defaults and take more control over what gets loaded — and when?
In this article, we’ll explore how to use dynamic imports effectively in Next.js to optimize performance — especially for components and libraries that aren’t required on initial load.
See Demos in Action
Want to explore similar examples? Check out this GitHub repo - nextjs-perf-showcase with demo projects.
Table of Contents
- Why Code Splitting Matters
- Static vs Dynamic Imports
- When Might You Use Dynamic Imports?
- Example: Dynamic Modal
- Dynamic Tabs
- Dynamically Load Heavy External Libraries
- Does Next.js Split Code by Page Automatically?
- Disabling SSR with next/dynamic
- Key Points to Remember
- Useful Links
1. Why Code Splitting Matters
Code splitting allows you to break your application into smaller JavaScript bundles (or “chunks”) that can be loaded on demand. This leads to:
- ⚡ Faster initial load times
- 📉 Reduced bundle sizes
- 📱 Better user experience — especially on slow or mobile networks
Without code splitting, users must download the entire app upfront — even if they only interact with a small part of it.
2. Static vs Dynamic Imports
With static imports:
import Component from "./MyComponent";
Everything is bundled and shipped during the initial load — even if the component isn’t used right away.
With dynamic imports:
const Component = dynamic(() => import("./MyComponent"));
The component is loaded only when it’s needed. This leads to smaller initial bundles and improved startup performance.
3. When Might You Use Dynamic Imports?
Use next/dynamic
when a component:
- 🧱 Is heavy (e.g., charts, video players, maps)
- 💬 Is conditionally visible or secondary UI (e.g., modals, tooltips, dropdowns, some carousels)
- 🔐 Is restricted or role-based UI (e.g., admin panels, private sections)
In short: if a component isn’t needed upfront, you might consider loading it dynamically.
4. Example: Dynamic Modal
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
const Modal = dynamic(() => import("../components/Modal"));
export default function ModalContainer() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
{isOpen && <Modal onClose={() => setIsOpen(false)} />}
</>
);
}
In this example, the modal component is excluded from the initial bundle and only loaded when needed. This helps keep your main bundle lightweight.
5. Dynamic Tabs
Tabs are a great use case for dynamic imports — especially when the content of each tab is heavy (e.g., charts, maps, or long lists) and not immediately visible.
Let’s say you have a basic tab interface with four components: TabOverview
, TabCharts
, TabReports
, and TabUsers
.
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
const TabOverview = dynamic(() => import("./tabs/TabOverview"));
const TabCharts = dynamic(() => import("./tabs/TabCharts"));
const TabReports = dynamic(() => import("./tabs/TabReports"));
const TabUsers = dynamic(() => import("./tabs/TabUsers"));
const tabs = {
tabOverview: TabOverview,
tabCharts: TabCharts,
tabReports: TabReports,
tabUsers: TabUsers,
};
export default function Dashboard() {
const [activeTab, setActiveTab] = useState("tabOverview");
const ActiveTabContent = tabs[activeTab];
return (
<div>
<button onClick={() => setActiveTab("tabOverview")}>Overview</button>
<button onClick={() => setActiveTab("tabCharts")}>Charts</button>
<button onClick={() => setActiveTab("tabReports")}>Reports</button>
<button onClick={() => setActiveTab("tabUsers")}>Users</button>
<div>
<ActiveTabContent />
</div>
</div>
);
}
6. Dynamically Load Heavy External Libraries
Heavy third-party libraries should also be imported dynamically. These libraries are often not needed on initial load, and can drastically increase your bundle size if included too early.
Let’s explore three practical examples.
🗺️ Example 1: Display a Map with leaflet
// src/app/contact/page.js (Server Component)
import MapSection from "@/components/MapSection";
export default function ContactPage() {
return (
<div>
<h1>Contact Us</h1>
<p>Email: foo@bar.com</p>
<p>Phone: 0123456789</p>
{/* Client Component for interactive part */}
<MapSection />
</div>
);
}
"use client";
import { useState, useRef, useEffect } from "react";
export default function MapSection() {
const [showMap, setShowMap] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const leafletRef = useRef(null);
const mapContainerRef = useRef(null);
const mapInstanceRef = useRef(null);
// Preload Leaflet on hover
async function preloadLeaflet() {
if (!leafletRef.current) {
leafletRef.current = await import("leaflet");
await import("leaflet/dist/leaflet.css");
}
}
async function loadMap() {
setShowMap(true);
setIsLoading(true);
try {
await preloadLeaflet();
// Create map
if (mapContainerRef.current && !mapInstanceRef.current) {
const L = leafletRef.current;
// Fix Leaflet default icon issue in Next.js
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl:
"https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
iconRetinaUrl:
"https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
shadowUrl:
"https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
});
// Initialize map
const map = L.map(mapContainerRef.current).setView([48.86, 2.35], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap contributors",
}).addTo(map);
L.marker([48.86, 2.35]).addTo(map).bindPopup("Our office");
mapInstanceRef.current = map;
}
} catch (error) {
console.error("Error loading map:", error);
} finally {
setIsLoading(false);
}
}
useEffect(() => {
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, []);
return (
<div>
<button
onMouseEnter={preloadLeaflet}
onClick={loadMap}
style={{
padding: "10px 20px",
fontSize: "16px",
cursor: "pointer",
}}
>
Show map
</button>
{showMap && (
<div
ref={mapContainerRef}
style={{
height: 400,
marginTop: 20,
position: "relative",
backgroundColor: isLoading ? "#f0f0f0" : "transparent",
}}
>
{isLoading && (
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1000,
}}
>
<p>Loading map...</p>
</div>
)}
</div>
)}
</div>
);
}
🎶 Example 2: Play an mp3 with wavesurfer
// src/app/player/page.js (Server Component)
import AudioPlayer from "@/components/AudioPlayer";
export default function AudioPlayerPage() {
// Here you could fetch audio metadata from an API
return (
<div>
{/* Client Component for interactive part */}
<AudioPlayer audioUrl="/file.mp3" />
</div>
);
}
// src/components/AudioPlayer.js
"use client";
import { useState, useRef, useEffect } from "react";
export default function AudioPlayer({ audioUrl }) {
const waveformRef = useRef(null);
const wavesurferRef = useRef(null);
const wavesurferLibRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// Preload WaveSurfer on hover
async function preloadWaveSurfer() {
if (!wavesurferLibRef.current) {
wavesurferLibRef.current = await import("wavesurfer.js");
}
}
async function handlePlayPause() {
// If already loaded, just toggle play/pause
if (wavesurferRef.current) {
wavesurferRef.current.playPause();
return;
}
// First time: load and play
setIsLoading(true);
setError(null);
try {
// Load library if not preloaded
await preloadWaveSurfer();
const WaveSurfer = wavesurferLibRef.current.default;
wavesurferRef.current = WaveSurfer.create({
container: waveformRef.current,
waveColor: "#4F4A85",
progressColor: "#383351",
url: audioUrl,
height: 80,
barWidth: 2,
barRadius: 3,
});
wavesurferRef.current.on("ready", () => {
setIsLoading(false);
// Auto-play after loading
wavesurferRef.current.play();
});
wavesurferRef.current.on("play", () => setIsPlaying(true));
wavesurferRef.current.on("pause", () => setIsPlaying(false));
wavesurferRef.current.on("finish", () => setIsPlaying(false));
wavesurferRef.current.on("error", (err) => {
setError(err.message);
setIsLoading(false);
});
} catch (err) {
setError("Failed to load audio player");
setIsLoading(false);
}
}
useEffect(() => {
return () => {
if (wavesurferRef.current) {
wavesurferRef.current.destroy();
wavesurferRef.current = null;
}
};
}, []);
return (
<div>
<div
ref={waveformRef}
style={{
height: "80px",
backgroundColor: "#f0f0f0",
borderRadius: "4px",
marginBottom: "10px",
}}
/>
{error && <p style={{ color: "red" }}>Error: {error}</p>}
<button
onClick={handlePlayPause}
onMouseEnter={preloadWaveSurfer}
disabled={isLoading}
style={{
padding: "10px 20px",
fontSize: "16px",
cursor: isLoading ? "not-allowed" : "pointer",
opacity: isLoading ? 0.6 : 1,
}}
>
{isLoading ? "Loading..." : isPlaying ? "⏸️ Pause" : "▶️ Play"}
</button>
</div>
);
}
🗺️ Example 3: Display charts with charts.js
// src/app/dashboard/page.js (Server Component)
import ChartSection from "@/components/ChartSection";
export default function DashboardPage() {
// Here you could fetch audio metadata from an API
return (
<div>
{/* Client Component for interactive part */}
<ChartSection />
</div>
);
}
// src/components/ChartSection.tsx
"use client";
import { useState, useRef, useEffect } from "react";
export default function ChartSection() {
const [showChart, setShowChart] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const chartRef = useRef(null);
const chartInstanceRef = useRef(null);
const chartLibRef = useRef(null);
// Create chart after canvas is rendered
useEffect(() => {
if (
showChart &&
chartRef.current &&
!chartInstanceRef.current &&
chartLibRef.current
) {
createChart();
}
}, [showChart]);
// Load Chart.js library
async function loadChartLibrary() {
if (chartLibRef.current) return chartLibRef.current;
const chartModule = await import("chart.js");
chartModule.Chart.register(...chartModule.registerables);
chartLibRef.current = chartModule;
return chartModule;
}
async function loadChart() {
if (chartInstanceRef.current) return; // Already loaded
setShowChart(true);
setIsLoading(true);
try {
// ✨ Dynamic Import - The core technique
await loadChartLibrary();
// The actual chart creation will happen in the useEffect
// after the canvas is rendered
} catch (error) {
console.error("Failed to load chart:", error);
} finally {
setIsLoading(false);
}
}
// Create chart once canvas and library are ready
function createChart() {
if (!chartRef.current || !chartLibRef.current) return;
try {
// Create the chart
chartInstanceRef.current = new chartLibRef.current.Chart(
chartRef.current,
{
type: "bar",
data: {
labels: ["Jan", "Feb", "Mar", "Apr", "May"],
datasets: [
{
label: "Sales (€)",
data: [1200, 1900, 800, 1500, 2200],
backgroundColor: "rgba(54, 162, 235, 0.2)",
borderColor: "rgba(54, 162, 235, 1)",
borderWidth: 2,
},
],
},
options: {
responsive: true,
scales: {
y: { beginAtZero: true },
},
},
}
);
} catch (error) {
console.error("Failed to create chart:", error);
}
}
// Preload Chart.js on hover
async function preloadChart() {
try {
await loadChartLibrary();
} catch (error) {
console.error("Failed to preload chart:", error);
}
}
// Cleanup when component is destroyed
useEffect(() => {
return () => {
if (chartInstanceRef.current) {
chartInstanceRef.current.destroy();
}
};
}, []);
return (
<div style={{ padding: "20px", maxWidth: "600px" }}>
<h2>Sales Dashboard</h2>
<button
onClick={loadChart}
onMouseEnter={preloadChart}
disabled={isLoading}
style={{
padding: "10px 20px",
fontSize: "16px",
backgroundColor: "#007bff",
color: "white",
border: "none",
borderRadius: "4px",
cursor: isLoading ? "not-allowed" : "pointer",
opacity: isLoading ? 0.6 : 1,
}}
>
{isLoading ? "Loading Chart..." : "📊 Load Chart"}
</button>
{showChart && (
<div style={{ marginTop: "20px", height: "400px" }}>
<canvas ref={chartRef} />
</div>
)}
</div>
);
}
🚀 Conclusion
Dynamic imports are a simple yet powerful way to improve performance in Next.js. By loading code only when it's truly needed — and anticipating user intent — you can significantly reduce initial load times without compromising interactivity.
The bundle size savings can be substantial:
- Chart.js: ~200KB+ (charts and visualizations)
- Leaflet: ~145KB+ (interactive maps)
- WaveSurfer: ~30KB+ (audio waveform rendering)
By dynamically importing these libraries, you can reduce your initial bundle by 375KB+ — that's faster load times, especially on mobile networks, and a better user experience for everyone.
7. Does Next.js Split Code by Page Automatically?
Yes. Next.js automatically splits your app by route — each page gets its own bundle. This means:
- Visiting
/about
won’t load code for/dashboard
- Navigation becomes faster
- You get automatic lazy-loading for each page
You don’t need to configure this — it’s built into both the Pages Router and the App Router.
8. Disabling SSR with next/dynamic
In some cases, you may want to load a component only in the browser, and skip rendering it on the server. Next.js allows this by using dynamic()
with ssr: false
.
This is especially useful when the component relies on browser-only APIs, uses external libraries that only run client-side, or simply doesn’t need to be pre-rendered on the server.
Here’s a quick example:
"use client";
import dynamic from "next/dynamic";
const TrueClientComponent = dynamic(
() => import("../components/TrueClientComponent"),
{ ssr: false }
);
export default function ClientComponent() {
return (
<div>
<TrueClientComponent />
</div>
);
}
By setting ssr: false
, the component is never rendered on the server — it’s fully skipped during SSR and only loaded on the client.
💡 Note (App Router)
In the App Router, components marked"use client"
are still rendered on the server by default.
Settingssr: false
ensures the component is only executed in the browser, giving you true client-only behavior.
9. Key Points to Remember
- Use dynamic imports to load "non-critical" code only when needed — for example: tabs, modals, or UI rendered conditionally.
- Disable SSR (
ssr: false
) when a component depends on browser-only APIs such aswindow
,document
, orlocalStorage
. - Next.js automatically splits pages and components into chunks to improve performance and load times.
- Dynamically import large external libraries to reduce the size of your initial JavaScript bundle.
Start small. Test your impact. Split only what makes sense.
Not everything needs to be lazy-loaded — but knowing when and how can make a real difference.
Useful Links
⚠️ Just a note
This article doesn’t claim to be exhaustive or perfect — if something seems off or outdated, feel free to dig deeper or let me know. Happy to hear from you :)
🙏 Thanks for reading!
If you found this useful or have feedback, feel free to reach out. Always happy to learn, and glad if it helped.
Top comments (0)