DEV Community

Cover image for Optimize Next.js Performance with Smart Code Splitting: What to Load, When, and Why
BoopyKiki
BoopyKiki

Posted on

Optimize Next.js Performance with Smart Code Splitting: What to Load, When, and Why

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

  1. Why Code Splitting Matters
  2. Static vs Dynamic Imports
  3. When Might You Use Dynamic Imports?
  4. Example: Dynamic Modal
  5. Dynamic Tabs
  6. Dynamically Load Heavy External Libraries
  7. Does Next.js Split Code by Page Automatically?
  8. Disabling SSR with next/dynamic
  9. Key Points to Remember
  10. 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";
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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)} />}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
"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>
  );
}
Enter fullscreen mode Exit fullscreen mode

🎶 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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

🗺️ 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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

🚀 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Setting ssr: false ensures the component is only executed in the browser, giving you true client-only behavior.

9. Key Points to Remember

  1. Use dynamic imports to load "non-critical" code only when needed — for example: tabs, modals, or UI rendered conditionally.
  2. Disable SSR (ssr: false) when a component depends on browser-only APIs such as window, document, or localStorage.
  3. Next.js automatically splits pages and components into chunks to improve performance and load times.
  4. 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)