How to Implement Lazy Loading in Next.js
Learn when and how to lazy load Next.js components, libraries, and images with next/dynamic, React.lazy, Suspense, and next/image.

Direct answer: In Next.js, use next/dynamic or React.lazy with Suspense for components, native import() when a user action should load a library, and next/image for viewport-based image loading. Keep dynamic imports explicit and at module scope. Use ssr: false only for Client Components that depend on browser APIs such as window. Server Components are already code split, so lazy loading is mainly useful for Client Components and on-demand libraries.
Lazy loading reduces the JavaScript needed to render the initial route. It can improve startup work, but it also adds a second request and a loading state. The right implementation depends on what you are deferring, which router you use, whether the component must render on the server, and what event should trigger loading.
What lazy loading means in Next.js
Next.js defines lazy loading as deferring Client Components and imported libraries so the initial route needs less JavaScript. A deferred module is fetched and evaluated when Next.js needs it instead of being included in the first render path. Server Components are automatically split, so you generally do not need to wrap them in a lazy-loading helper.
| Target | Recommended mechanism | Typical trigger | Can render on server? |
|---|---|---|---|
| Client Component | next/dynamic |
Initial render or conditional display | Yes, unless ssr: false |
| Client Component with Suspense | React.lazy and <Suspense> |
Render path or route state | Depends on the component and router setup |
| Large browser library | Native dynamic import() |
User action such as typing or opening a dialog | Usually loaded in a Client Component |
| Images | next/image with loading='lazy' |
Near the viewport | Image request is deferred by the browser |
Lazy load a component with next/dynamic
next/dynamic is the most direct Next.js API. Put the call at module scope and keep the import path explicit. This allows Next.js to match the module to a bundle and preload it correctly when appropriate.

\'use client\'
import dynamic from \'next/dynamic\'
const Chart = dynamic(() => import(\'../components/Chart\'), {
loading: () => <p>Loading chart…</p>,
})
export default function Dashboard() {
return (
<main>
<h1>Revenue</h1>
<Chart />
</main>
)
}
The imported component should have a default export. If it uses a named export, map that export in the dynamic import:
import dynamic from \'next/dynamic\'
const ReportTable = dynamic(
() => import(\'../components/ReportTable\').then((mod) => mod.ReportTable),
{ loading: () => <p>Loading table…</p> }
)
Defer a component until it is needed
For modals, editors, maps, and charts that are not always shown, combine a dynamic import with conditional rendering. The module is not needed for users who never open the feature.
\'use client\'
import { useState } from \'react\'
import dynamic from \'next/dynamic\'
const SettingsModal = dynamic(() => import(\'../components/SettingsModal\'))
export default function AccountActions() {
const [open, setOpen] = useState(false)
return (
<>
<button type='button' onClick={() => setOpen(true)}>
Settings
</button>
{open ? <SettingsModal onClose={() => setOpen(false)} /> : null}
</>
)
}
For a component opened after a click, consider preloading on intent if the interaction has a predictable delay. For example, you can start an import on pointer hover, then render the component after the click. Keep the import path literal so the bundler can analyze it.
Use React.lazy and Suspense
React also provides component-level lazy loading. This is useful when you already use Suspense boundaries or want the component to follow standard React patterns.
\'use client\'
import { lazy, Suspense } from \'react\'
const Chart = lazy(() => import(\'../components/Chart\'))
export default function Dashboard() {
return (
<Suspense fallback={<p>Loading chart…</p>}>
<Chart />
</Suspense>
)
}
Use a fallback that reserves approximately the final component’s space. A stable skeleton reduces layout movement and gives keyboard and screen-reader users a clear status. Suspense boundaries can be nested so a slow secondary widget does not replace the entire page fallback.
Disable SSR for browser-only components
Some components cannot render on the server because they access window, document, a browser-only SDK, or a DOM measurement during render. Mark the parent file with 'use client' and set ssr: false on the dynamic component.
\'use client\'
import dynamic from \'next/dynamic\'
const Map = dynamic(() => import(\'../components/Map\'), {
ssr: false,
loading: () => <div aria-busy='true'>Loading map…</div>,
})
export default function StoreLocator() {
return <Map />
}
ssr: false is supported for Client Components. It is not supported in a Server Component, so move the dynamic declaration into a Client Component boundary. Do not use it simply because a component is large; disabling server rendering can hurt the first meaningful render and SEO for content that could render safely on the server.
Lazy load an external library after user input
For a large package, native import() lets you defer both download and initialization until the feature is used. The Next.js guide demonstrates this pattern with Fuse.js.
\'use client\'
import { useState } from \'react\'
export default function Search({ items }) {
const [results, setResults] = useState(items)
const onSearch = async (value) => {
if (!value) {
setResults(items)
return
}
const Fuse = (await import(\'fuse.js\')).default
const fuse = new Fuse(items, { keys: [\'title\'] })
setResults(fuse.search(value).map((result) => result.item))
}
return (
<>
<input onChange={(event) => onSearch(event.target.value)} />
<ul>
{results.map((item) => <li key={item.id}>{item.title}</li>)}
</ul>
</>
)
}
Do not import the same library in the top-level module as well; that puts it back into the initial bundle. For search inputs, debounce the handler and cache the imported module if initialization is expensive.
Lazy loading by router and route segment
App Router
In the App Router, Server Components are split automatically. Add 'use client' where state, event handlers, or browser APIs are required, then lazy load the expensive Client Component from that boundary. A route-level app/segment/loading.tsx file provides an instant streamed fallback while the segment is loading.
// app/dashboard/loading.tsx
export default function Loading() {
return <div aria-busy='true'>Loading dashboard…</div>
}
The route loading convention is useful when the whole segment has asynchronous work. Use a component-level fallback when only one chart, editor, or panel is deferred.
Pages Router
The same next/dynamic API works in the Pages Router. The import must remain inside the dynamic() call, with an explicit path rather than a template string or variable. This lets Next.js identify the module and its bundle.
import dynamic from \'next/dynamic\'
const Editor = dynamic(() => import(\'../components/Editor\'), {
loading: () => <p>Loading editor…</p>,
})
export default function EditPage() {
return <Editor />
}
Lazy load images with next/image
next/image uses native lazy loading by default for images near the viewport. You can state it explicitly for clarity:
import Image from \'next/image\'
export default function ArticleImage() {
return (
<Image
src='/hero.jpg'
alt='A person reviewing a dashboard'
width={1200}
height={800}
loading='lazy'
/>
)
}
Use loading='eager' for an image that must load immediately, such as a carefully selected above-the-fold image. Use eager loading selectively. Native lazy loading can fall back to eager behavior in browsers older than Safari 15.4. Always provide accurate dimensions or a stable aspect ratio to avoid layout shifts.
Loading states, accessibility, and error handling
A lazy boundary should communicate that content is pending and should recover when a chunk fails. Give skeletons a meaningful accessible label, preserve the intended layout, and place an error boundary around optional features.
\'use client\'
import { Component } from \'react\'
export class WidgetBoundary extends Component {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
render() {
if (this.state.failed) {
return <p role='alert'>This widget could not load. Reload to try again.</p>
}
return this.props.children
}
}
Do not hide essential navigation, headings, or primary content behind a lazy boundary without a usable fallback. If a component appears after a user action, return focus to a predictable element when it closes.
Choosing the right approach
| Question | Choice |
|---|---|
| Is it a Client Component rendered as part of the page? | next/dynamic with a loading fallback. |
| Is it only needed after a click, hover, or input? | Conditional next/dynamic or native import() at the event. |
Does render require window or document? |
Client Component plus ssr: false. |
| Is it an image near the viewport? | next/image; lazy loading is the default. |
| Is it a Server Component? | Leave it as a Server Component; Next.js already code splits it. |
| Do you need a route-wide fallback? | Add loading.tsx in the App Router segment. |

Performance and reliability checklist
- Measure the initial JavaScript and route performance before changing code. No universal percentage improvement applies; results depend on the modules and route.
- Find large Client Components and libraries in your build output, then defer only code that is not needed for the first interaction.
- Keep dynamic imports explicit and at module scope. Avoid template strings and variable paths.
- Use conditional rendering so an unopened modal or unused chart is not fetched.
- Choose a fallback that reserves space and does not cause layout shift.
- Prefetch only when the user is likely to need the feature; unnecessary prefetching removes the benefit.
- Test slow networks, JavaScript-disabled behavior where relevant, repeated navigation, and a failed chunk request.
- Keep browser-only code out of server render paths instead of masking server errors with
ssr: false. - For images, use correct dimensions, responsive sizing, and eager loading only for selected above-the-fold content.
Common errors and fixes
| Error or symptom | Cause | Fix |
|---|---|---|
ssr: false is rejected |
The dynamic component is declared in a Server Component. | Move the declaration into a file with 'use client'. |
| Browser API error during build or render | window or document runs during server rendering. |
Use a Client Component and ssr: false, or move the API call into an effect or event handler. |
| The module stays in the initial bundle | A top-level static import remains, or the import path is not analyzable. | Remove the static import and use an explicit literal path inside dynamic() or import(). |
| Loading UI never appears | The module is already cached or the fallback is outside the boundary. | Put the fallback in loading or Suspense directly around the lazy component. |
| Hydration mismatch | Server and browser render different markup, often because of browser-only values. | Defer browser-dependent rendering and ensure the initial markup is deterministic. |
| Layout jumps when content arrives | The fallback has no reserved dimensions. | Give the skeleton the component’s expected height, width, or aspect ratio. |
| Chunk load failure after deployment | A cached page references an old build’s chunk. | Serve immutable build assets correctly and provide a retry or full reload path in the error boundary. |
| Images load immediately | The image is above the viewport, uses eager, or the browser does not support native lazy loading. |
Use the default lazy behavior for below-fold images and verify browser support and image placement. |
Or skip the browser setup
If your goal is to obtain a clean screenshot of a Next.js page after it has loaded, ScreenshotNeo provides a website screenshot API. One GET request returns PNG, JPEG, WebP, or PDF output. See the ScreenshotNeo documentation for the complete parameter reference.
curl -G 'https://api.screenshotneo.com/v1/shot' \\
-d access_key=YOUR_API_KEY \\
--data-urlencode url=https://stripe.com \\
-o shot.webp
import requests
r = requests.get(
'https://api.screenshotneo.com/v1/shot',
params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'},
timeout=90,
)
open('shot.webp', 'wb').write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com',
})
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`)
ScreenshotNeo accepts cookie and consent banners as a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and whether the shot was billed. It also offers an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
For Next.js pages, relevant options include full-page capture with lazy images loaded, CSS-element capture, dark mode, device presets or a custom viewport, retina scale, custom CSS and JavaScript, selector waits, delay or network-idle waits, request blocking, custom headers and cookies, timezone and geolocation, image resizing, caching with a chosen TTL, signed links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, and PDF page settings. Every feature is available on every plan. The Free plan includes 1,000 shots per month without a card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account to try it.
Cost and operational notes
Lazy loading itself has no separate Next.js charge, but deferred modules still consume bandwidth and browser work when fetched. Measure the total experience, including the interaction that triggers a chunk. For ScreenshotNeo, only clean shots are billed; inspect X-Page-Verdict and X-Billed when you need to reconcile usage. Caching can reduce repeated captures, while asynchronous jobs and signed webhooks help keep long-running capture work out of a request timeout.
FAQ
Should I use next/dynamic or React.lazy?
Use next/dynamic when you want Next.js-specific options such as ssr: false and a built-in loading component. Use React.lazy when a Suspense boundary already describes your loading behavior.
Do Server Components need lazy loading?
Usually no. Next.js automatically code splits Server Components. Focus manual lazy loading on Client Components and libraries that are not required for the initial route.
Can I use a variable import path?
Keep the path explicit. Next.js needs to map the import to a known module and bundle.
Does lazy loading improve Core Web Vitals automatically?
No. It can reduce initial JavaScript, but a poor fallback, late above-the-fold content, or extra network request can offset the benefit. Measure the route and the triggered interaction.
How do I capture a page after its lazy content appears?
Use a capture service that can wait for a selector, a delay, or network idle. ScreenshotNeo supports all three, along with full-page capture that loads lazy images.


