Skip to main content
Back to Blog
Tutorials
2 min read
January 16, 2025

How to Implement Lazy Module Loading Patterns in Next.js

Master advanced lazy loading patterns with dynamic imports, route-based splitting, conditional loading, and preloading strategies.

Ryel Banfield

Founder & Lead Developer

Lazy loading keeps your initial bundle small and loads code only when needed. Here are production patterns.

Dynamic Component Import

import dynamic from "next/dynamic";

// Basic dynamic import with loading state
const HeavyChart = dynamic(() => import("@/components/HeavyChart"), {
  loading: () => (
    <div className="h-64 bg-muted animate-pulse rounded-lg" />
  ),
});

// Skip SSR for client-only components
const MapComponent = dynamic(() => import("@/components/Map"), {
  ssr: false,
  loading: () => (
    <div className="h-96 bg-muted animate-pulse rounded-lg flex items-center justify-center">
      <span className="text-muted-foreground text-sm">Loading map...</span>
    </div>
  ),
});

Intersection Observer Lazy Loading

"use client";

import { useRef, useState, useEffect, type ComponentType } from "react";

interface LazyComponentProps<P> {
  loader: () => Promise<{ default: ComponentType<P> }>;
  props: P;
  rootMargin?: string;
  placeholder?: React.ReactNode;
}

export function LazyComponent<P extends Record<string, unknown>>({
  loader,
  props,
  rootMargin = "200px",
  placeholder,
}: LazyComponentProps<P>) {
  const ref = useRef<HTMLDivElement>(null);
  const [Component, setComponent] = useState<ComponentType<P> | null>(null);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          loader().then((mod) => setComponent(() => mod.default));
          observer.disconnect();
        }
      },
      { rootMargin },
    );

    observer.observe(element);
    return () => observer.disconnect();
  }, [loader, rootMargin]);

  if (Component) return <Component {...props} />;

  return (
    <div ref={ref}>
      {placeholder ?? (
        <div className="h-48 bg-muted animate-pulse rounded-lg" />
      )}
    </div>
  );
}

Usage:

<LazyComponent
  loader={() => import("@/components/HeavyChart")}
  props={{ data: chartData }}
  rootMargin="400px"
/>

Conditional Feature Loading

"use client";

import { useState, useCallback, type ComponentType } from "react";

type LazyModule<P> = { default: ComponentType<P> };

export function useConditionalModule<P>() {
  const [Module, setModule] = useState<ComponentType<P> | null>(null);
  const [loading, setLoading] = useState(false);

  const load = useCallback(async (loader: () => Promise<LazyModule<P>>) => {
    setLoading(true);
    try {
      const mod = await loader();
      setModule(() => mod.default);
    } finally {
      setLoading(false);
    }
  }, []);

  return { Module, loading, load };
}

// Usage
function AdminPanel() {
  const { Module: AdvancedEditor, loading, load } = useConditionalModule();

  return (
    <div>
      <button
        onClick={() => load(() => import("@/components/AdvancedEditor"))}
        disabled={loading}
        className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
      >
        {loading ? "Loading..." : "Open Advanced Editor"}
      </button>

      {AdvancedEditor && <AdvancedEditor />}
    </div>
  );
}

Route-Based Preloading

"use client";

import Link from "next/link";
import { useCallback } from "react";

// Preload a module when the user hovers
const moduleCache = new Map<string, Promise<unknown>>();

function preloadModule(path: string) {
  if (moduleCache.has(path)) return;
  const promise = import(/* webpackPrefetch: true */ `@/components/${path}`);
  moduleCache.set(path, promise);
}

export function PreloadLink({
  href,
  preload,
  children,
  className,
}: {
  href: string;
  preload: string;
  children: React.ReactNode;
  className?: string;
}) {
  const handleMouseEnter = useCallback(() => {
    preloadModule(preload);
  }, [preload]);

  return (
    <Link
      href={href}
      className={className}
      onMouseEnter={handleMouseEnter}
      onFocus={handleMouseEnter}
    >
      {children}
    </Link>
  );
}

Heavy Library Loading

"use client";

import { useCallback, useState, useRef } from "react";

// Load heavy libraries on demand
export function PdfGenerator() {
  const [generating, setGenerating] = useState(false);
  const pdfLibRef = useRef<typeof import("@react-pdf/renderer") | null>(null);

  const generatePdf = useCallback(async () => {
    setGenerating(true);
    try {
      // Only load the library when needed
      if (!pdfLibRef.current) {
        pdfLibRef.current = await import("@react-pdf/renderer");
      }

      const { pdf, Document, Page, Text } = pdfLibRef.current;

      const doc = (
        <Document>
          <Page>
            <Text>Generated PDF content</Text>
          </Page>
        </Document>
      );

      const blob = await pdf(doc).toBlob();
      const url = URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = url;
      link.download = "document.pdf";
      link.click();
      URL.revokeObjectURL(url);
    } finally {
      setGenerating(false);
    }
  }, []);

  return (
    <button
      onClick={generatePdf}
      disabled={generating}
      className="px-4 py-2 bg-primary text-primary-foreground rounded-md disabled:opacity-50"
    >
      {generating ? "Generating..." : "Download PDF"}
    </button>
  );
}

Idle Loading Pattern

"use client";

import { useEffect, useState, type ComponentType } from "react";

export function useIdleLoad<P>(loader: () => Promise<{ default: ComponentType<P> }>) {
  const [Component, setComponent] = useState<ComponentType<P> | null>(null);

  useEffect(() => {
    if ("requestIdleCallback" in window) {
      const id = requestIdleCallback(() => {
        loader().then((mod) => setComponent(() => mod.default));
      });
      return () => cancelIdleCallback(id);
    }

    // Fallback for Safari
    const timeout = setTimeout(() => {
      loader().then((mod) => setComponent(() => mod.default));
    }, 2000);
    return () => clearTimeout(timeout);
  }, [loader]);

  return Component;
}

Need Performance Optimization?

We optimize web applications for speed with code splitting and lazy loading. Contact us to improve your site.

lazy loadingdynamic importcode splittingperformanceNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles