AI & ML

Mastering Lazy Loading in React and Next.js: A Practical Developer's Guide

Apr 14, 2026 5 min read views

Large JavaScript bundles are one of the most common culprits behind sluggish web applications. When the browser has to download and parse too much code upfront, users stare at a blank screen longer, pages feel unresponsive, and search engines may penalize your site in rankings.

Lazy loading addresses this directly: instead of shipping everything at once, you split your code into smaller chunks and load each one only when it's actually needed.

This guide covers lazy loading in both React and Next.js. By the end, you'll understand when to reach for React.lazy, next/dynamic, or Suspense—and you'll have working examples ready to drop into your own projects.

Table of Contents

What is Lazy Loading?

Lazy loading is a performance technique that defers the download of code until the moment it's required. Rather than bundling your entire application into a single file, you split it into smaller chunks that the browser fetches on demand—when a user navigates to a route or triggers a specific feature.

The practical benefits are meaningful:

  • Faster initial load: A smaller first bundle means the browser reaches an interactive state sooner.

  • Better Core Web Vitals: Reducing upfront JavaScript directly improves Largest Contentful Paint and Total Blocking Time.

  • Lower bandwidth consumption: Users only download the code paths they actually visit.

In React, you implement lazy loading through dynamic imports combined with React.lazy(). In Next.js, the framework provides its own wrapper via next/dynamic.

Prerequisites

Before following along, you should have:

  • Basic familiarity with React—components, hooks, and state

  • Node.js installed (version 18 or later recommended)

  • An existing React app (Create React App or Vite) or a Next.js project for the framework-specific examples

The Next.js examples use the App Router introduced in Next.js 13. If you're on the Pages Router, some patterns will differ.

How to Use React.lazy for Code Splitting

React.lazy() lets you define a component that React will only load when it first needs to render it. It accepts a function that returns a dynamic import(), and the target module must use a default export.

Here's the basic pattern:

import { lazy } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));
const AdminDashboard = lazy(() => import('./AdminDashboard'));

function App() {
  return (
    <div>
      <h1>My App</h1>
      <HeavyChart />
      <AdminDashboard />
    </div>
  );
}

If your component uses a named export rather than a default export, map it manually using .then():

const ComponentWithNamedExport = lazy(() =>
  import('./MyComponent').then((module) => ({
    default: module.NamedComponent,
  }))
);

You can also assign readable chunk names to make bundle analysis easier in browser DevTools:

const HeavyChart = lazy(() =>
  import(/* webpackChunkName: "heavy-chart" */ './HeavyChart')
);

One important caveat: React.lazy() alone isn't sufficient. You must wrap lazy-loaded components in a Suspense boundary so React knows what to render while the chunk is being fetched.

How to Use Suspense with React.lazy

Suspense is a React component that displays a fallback UI while its children are still loading. Paired with React.lazy(), it handles the in-between state so users see something meaningful instead of nothing.

Wrap each lazy component—or a group of them—in a Suspense boundary and provide a fallback prop:

import { lazy, Suspense } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));
const AdminDashboard = lazy(() => import('./AdminDashboard'));

function App() {
  return (
    <div>
      <h1>My App</h1>
      <Suspense fallback={<div>Loading chart...</div>}>
        <HeavyChart />
      </Suspense>
      <Suspense fallback={<div>Loading dashboard...</div>}>
        <AdminDashboard />
      </Suspense>
    </div>
  );
}

A single Suspense boundary can cover multiple lazy components if you want them to load together:

<Suspense fallback={<div>Loading...</div>}>
  <HeavyChart />
  <AdminDashboard />
</Suspense>

A polished fallback goes a long way toward perceived performance. Rather than a bare text string, consider a spinner component:

function LoadingSpinner() {
  return (
    <div className="loading-container">
      <div className="spinner" />
      <p>Loading...</p>
    </div>
  );
}

<Suspense fallback={<LoadingSpinner />}>
  <HeavyChart />
</Suspense>

How to Handle Errors with Error Boundaries

React.lazy() and Suspense handle the loading state, but they won't catch failures—network errors, missing chunks, or bad imports will propagate unchecked. For that, you need an Error Boundary.

Error Boundaries are class components that use static getDerivedStateFromError or componentDidCatch to intercept errors thrown by their children and render a fallback instead.

Here's a reusable implementation:

import { Component } from 'react';

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Error caught by boundary:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <div>Something went wrong.</div>;
    }
    return this.props.children;
  }
}

Nest your Suspense boundary inside an Error Boundary so both loading and failure states are handled gracefully:

import { lazy, Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';

const HeavyChart = lazy(() => import('./HeavyChart'));

function App() {
  return (
    <ErrorBoundary fallback={<div>Failed to load chart. Please try again.</div>}>
      <Suspense fallback={<div>Loading chart...</div>}>
        <HeavyChart />
      </Suspense>
    </ErrorBoundary>
  );
}

If the chunk fails to fetch, the Error Boundary catches the exception and renders your fallback—preventing a blank screen or an unhandled runtime error from reaching the user.

How to Use next/dynamic in Next.js

Next.js ships its own dynamic import helper, next/dynamic, which wraps React.lazy() and Suspense while adding Next.js-specific options—most notably, control over Server-Side Rendering.

Basic usage looks like this:

'use client';
import dynamic from 'next/dynamic';

const ComponentA = dynamic(() => import('../components/A'));
const ComponentB = dynamic(() => import('../components/B'));

export default function Page() {
  return (
    <div>
      <ComponentA />
      <ComponentB />
    </div>
  );
}

Custom Loading UI

Pass a loading option to display a placeholder while the component fetches:

const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
  loading: () => <p>Loading chart...</p>,
});

Disable Server-Side Rendering

Components that depend on browser-only APIs—window, document, third-party widgets—will break during SSR. Set ssr: false to skip server rendering entirely:

const ClientOnlyMap = dynamic(() => import('../components/Map'), {
  ssr: false,
  loading: () => <p>Loading map...</p>,
});

Note: ssr: false is only valid inside a 'use client' file. It has no effect in Server Components.

Load on Demand

You can defer a component's download until a specific condition is met—for example, when a user clicks a button to open a modal:

'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';

const Modal = dynamic(() => import('../components/Modal'), {
  loading: () => <p>Opening modal...</p>,
});

export default function Page() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div>
      <button onClick={() => setShowModal(true)}>Open Modal</button>
      {showModal && <Modal onClose={() => setShowModal(false)} />}
    </div>
  );
}

Named Exports

To import a named export dynamically, resolve it explicitly in the .then() callback:

const Hello = dynamic(() =>
  import('../components/hello').then((mod) => mod.Hello)
);

Using Suspense with next/dynamic

In React 18 and later, you can opt into Suspense-based loading by setting suspense: true. This delegates loading state to a parent Suspense boundary instead of the built-in loading option:

const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
  suspense: true,
});

// In your component:
<Suspense fallback={<div>Loading...</div>}>
  <HeavyChart />
</Suspense>

Important: When suspense: true is set, you cannot also use ssr: false or the loading option. The Suspense fallback takes over that responsibility.

React.lazy vs next/dynamic: When to Use Each

Feature React.lazy + Suspense next/dynamic
Framework Any React app (Create React App, Vite, etc.) Next.js only
Server-Side Rendering Not supported Supported by default
Disable SSR N/A ssr: false option
Loading UI Suspense fallback prop Built-in loading option
Error handling Requires Error Boundary Requires Error Boundary
Named exports Manual .then() mapping Same .then() pattern
Suspense mode Always uses Suspense Optional via suspense: true

When to Use React.lazy

  • You're building a pure React app without Next.js

  • Your project uses Create React App, Vite, or a custom Webpack setup

  • You don't need server-side rendering

  • You prefer a straightforward, framework-agnostic solution

When to Use next/dynamic

  • You're working within a Next.js application

  • You require SSR for certain components while disabling it for others

  • You want integrated loading states without manually implementing Suspense

  • You need Next.js-specific performance optimizations and conventions

Real-World Examples

Example 1: Route-Based Code Splitting in React

Implement route-level code splitting so each page loads its JavaScript only when users navigate to it:

// App.jsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import ErrorBoundary from './ErrorBoundary';

const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <ErrorBoundary fallback={<div>Failed to load page.</div>}>
        <Suspense fallback={<div>Loading page...</div>}>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/dashboard" element={<Dashboard />} />
            <Route path="/settings" element={<Settings />} />
          </Routes>
        </Suspense>
      </ErrorBoundary>
    </BrowserRouter>
  );
}

Example 2: Lazy Loading a Heavy Chart Library in Next.js

Defer loading visualization libraries until users actually need them:

// app/analytics/page.jsx
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('../components/Chart'), {
  ssr: false,
  loading: () => (
    <div className="chart-skeleton">
      <div className="skeleton-bar" />
      <div className="skeleton-bar" />
      <div className="skeleton-bar" />
    </div>
  ),
});

export default function AnalyticsPage() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <h1>Analytics</h1>
      <button onClick={() => setShowChart(true)}>Load Chart</button>
      {showChart && <Chart />}
    </div>
  );
}

Example 3: Lazy Loading a Modal

Load modal components on-demand when users trigger them:

// React (with React.lazy)
import { lazy, Suspense, useState } from 'react';

const Modal = lazy(() => import('./Modal'));

function ProductPage() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div>
      <button onClick={() => setShowModal(true)}>Add to Cart</button>
      {showModal && (
        <Suspense fallback={null}>
          <Modal onClose={() => setShowModal(false)} />
        </Suspense>
      )}
    </div>
  );
}
// Next.js (with next/dynamic)
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';

const Modal = dynamic(() => import('./Modal'), {
  loading: () => null,
});

export default function ProductPage() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div>
      <button onClick={() => setShowModal(true)}>Add to Cart</button>
      {showModal && <Modal onClose={() => setShowModal(false)} />}
    </div>
  );
}

Example 4: Lazy Loading External Libraries

Import third-party libraries dynamically when functionality is actually invoked:

'use client';
import { useState } from 'react';

const names = ['Alice', 'Bob', 'Charlie', 'Diana'];

export default function SearchPage() {
  const [results, setResults] = useState([]);
  const [query, setQuery] = useState('');

  const handleSearch = async (value) => {
    setQuery(value);
    if (!value) {
      setResults([]);
      return;
    }

    // Load fuse.js only when user searches
    const Fuse = (await import('fuse.js')).default;
    const fuse = new Fuse(names);
    setResults(fuse.search(value));
  };

  return (
    <div>
      <input
        type="text"
        placeholder="Search..."
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
      />
      <ul>
        {results.map((result) => (
          <li key={result.refIndex}>{result.item}</li>
        ))}
      </ul>
    </div>
  );
}

Conclusion

Lazy loading reduces initial bundle size and improves load times by deferring non-critical code. The key takeaways:

  • React.lazy() handles code splitting in React applications through dynamic imports and requires default exports.

  • Suspense manages loading states by wrapping lazy components with a fallback UI.

  • Error Boundaries catch chunk loading failures and display user-friendly error messages.

  • next/dynamic provides the same capabilities in Next.js with additional SSR controls and built-in loading states.

Use React.lazy for standard React projects and next/dynamic for Next.js applications. Pair them with Suspense and Error Boundaries for robust lazy loading.

Start by identifying resource-intensive components like charts, modals, and admin interfaces. Measure bundle size and Core Web Vitals before and after implementation to quantify performance gains.