Lazy Loading
Patterns for lazy loading components, data, and assets on demand.
Overview#
Lazy loading improves initial page load by deferring non-critical resources. This pattern covers:
- Dynamic imports with next/dynamic
- React.lazy with Suspense
- Intersection Observer
- Virtual lists
- Image lazy loading
Prerequisites#
npm install @tanstack/react-virtualCode Example#
Dynamic Imports#
1// app/page.tsx
2import dynamic from 'next/dynamic'
3
4// Basic dynamic import
5const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
6 loading: () => <div className="h-64 animate-pulse bg-gray-200" />
7})
8
9// With SSR disabled (client-only component)
10const MapComponent = dynamic(
11 () => import('@/components/Map'),
12 { ssr: false }
13)
14
15// Named exports
16const Modal = dynamic(
17 () => import('@/components/Modals').then(mod => mod.Modal)
18)
19
20export default function Page() {
21 return (
22 <div>
23 <HeavyChart data={chartData} />
24 <MapComponent location={location} />
25 </div>
26 )
27}React.lazy with Suspense#
1// components/Dashboard.tsx
2import { Suspense, lazy } from 'react'
3
4const Analytics = lazy(() => import('./Analytics'))
5const Reports = lazy(() => import('./Reports'))
6const Settings = lazy(() => import('./Settings'))
7
8export function Dashboard({ tab }: { tab: string }) {
9 return (
10 <div>
11 <Suspense fallback={<TabSkeleton />}>
12 {tab === 'analytics' && <Analytics />}
13 {tab === 'reports' && <Reports />}
14 {tab === 'settings' && <Settings />}
15 </Suspense>
16 </div>
17 )
18}
19
20function TabSkeleton() {
21 return (
22 <div className="space-y-4 p-4">
23 <div className="h-8 w-1/3 animate-pulse rounded bg-gray-200" />
24 <div className="h-64 animate-pulse rounded bg-gray-200" />
25 </div>
26 )
27}Intersection Observer#
1// components/LazySection.tsx
2'use client'
3
4import { useRef, useEffect, useState, ReactNode } from 'react'
5
6interface Props {
7 children: ReactNode
8 fallback?: ReactNode
9 rootMargin?: string
10}
11
12export function LazySection({
13 children,
14 fallback = <div className="h-64" />,
15 rootMargin = '100px'
16}: Props) {
17 const ref = useRef<HTMLDivElement>(null)
18 const [isVisible, setIsVisible] = useState(false)
19
20 useEffect(() => {
21 const observer = new IntersectionObserver(
22 ([entry]) => {
23 if (entry.isIntersecting) {
24 setIsVisible(true)
25 observer.disconnect()
26 }
27 },
28 { rootMargin }
29 )
30
31 if (ref.current) {
32 observer.observe(ref.current)
33 }
34
35 return () => observer.disconnect()
36 }, [rootMargin])
37
38 return (
39 <div ref={ref}>
40 {isVisible ? children : fallback}
41 </div>
42 )
43}
44
45// Usage
46<LazySection>
47 <HeavyComponent />
48</LazySection>Lazy Image Loading#
1// components/LazyImage.tsx
2'use client'
3
4import Image from 'next/image'
5import { useState } from 'react'
6
7interface Props {
8 src: string
9 alt: string
10 width: number
11 height: number
12 className?: string
13}
14
15export function LazyImage({ src, alt, width, height, className }: Props) {
16 const [isLoaded, setIsLoaded] = useState(false)
17
18 return (
19 <div className={`relative ${className}`}>
20 {/* Blur placeholder */}
21 {!isLoaded && (
22 <div
23 className="absolute inset-0 animate-pulse bg-gray-200"
24 style={{ aspectRatio: `${width}/${height}` }}
25 />
26 )}
27
28 <Image
29 src={src}
30 alt={alt}
31 width={width}
32 height={height}
33 loading="lazy"
34 onLoad={() => setIsLoaded(true)}
35 className={`transition-opacity duration-300 ${
36 isLoaded ? 'opacity-100' : 'opacity-0'
37 }`}
38 />
39 </div>
40 )
41}
42
43// With blur placeholder from Next.js
44export function BlurImage({
45 src,
46 alt,
47 blurDataUrl,
48 ...props
49}: Props & { blurDataUrl: string }) {
50 return (
51 <Image
52 src={src}
53 alt={alt}
54 {...props}
55 loading="lazy"
56 placeholder="blur"
57 blurDataURL={blurDataUrl}
58 />
59 )
60}Virtual List#
1// components/VirtualList.tsx
2'use client'
3
4import { useVirtualizer } from '@tanstack/react-virtual'
5import { useRef } from 'react'
6
7interface Props<T> {
8 items: T[]
9 itemHeight: number
10 renderItem: (item: T, index: number) => React.ReactNode
11}
12
13export function VirtualList<T>({ items, itemHeight, renderItem }: Props<T>) {
14 const parentRef = useRef<HTMLDivElement>(null)
15
16 const virtualizer = useVirtualizer({
17 count: items.length,
18 getScrollElement: () => parentRef.current,
19 estimateSize: () => itemHeight,
20 overscan: 5
21 })
22
23 return (
24 <div
25 ref={parentRef}
26 className="h-[500px] overflow-auto"
27 >
28 <div
29 style={{
30 height: `${virtualizer.getTotalSize()}px`,
31 position: 'relative'
32 }}
33 >
34 {virtualizer.getVirtualItems().map(virtualItem => (
35 <div
36 key={virtualItem.key}
37 style={{
38 position: 'absolute',
39 top: 0,
40 left: 0,
41 width: '100%',
42 height: `${virtualItem.size}px`,
43 transform: `translateY(${virtualItem.start}px)`
44 }}
45 >
46 {renderItem(items[virtualItem.index], virtualItem.index)}
47 </div>
48 ))}
49 </div>
50 </div>
51 )
52}
53
54// Usage
55<VirtualList
56 items={largeDataset}
57 itemHeight={60}
58 renderItem={(item) => <ListItem item={item} />}
59/>Conditional Feature Loading#
1// components/FeatureLoader.tsx
2'use client'
3
4import dynamic from 'next/dynamic'
5
6const features = {
7 analytics: dynamic(() => import('@/features/Analytics')),
8 collaboration: dynamic(() => import('@/features/Collaboration')),
9 aiAssistant: dynamic(() => import('@/features/AIAssistant'))
10}
11
12interface Props {
13 enabledFeatures: (keyof typeof features)[]
14}
15
16export function FeatureLoader({ enabledFeatures }: Props) {
17 return (
18 <>
19 {enabledFeatures.map(feature => {
20 const Component = features[feature]
21 return <Component key={feature} />
22 })}
23 </>
24 )
25}Preloading on Intent#
1// components/Navigation.tsx
2import Link from 'next/link'
3import dynamic from 'next/dynamic'
4
5// Preload the heavy modal component
6const HeavyModal = dynamic(() => import('@/components/HeavyModal'))
7
8export function Navigation() {
9 // Preload when user shows intent
10 function handleMouseEnter() {
11 import('@/components/HeavyModal')
12 }
13
14 return (
15 <nav>
16 {/* Prefetch on hover/focus (default behavior) */}
17 <Link href="/dashboard" prefetch={true}>
18 Dashboard
19 </Link>
20
21 {/* Disable prefetch for rarely visited */}
22 <Link href="/settings" prefetch={false}>
23 Settings
24 </Link>
25
26 {/* Preload component on hover */}
27 <button onMouseEnter={handleMouseEnter}>
28 Open Modal
29 </button>
30 </nav>
31 )
32}Route-Based Code Splitting#
1// Next.js App Router automatically code-splits by route
2// app/
3// |-- page.tsx -> main chunk
4// |-- dashboard/
5// | |-- page.tsx -> dashboard chunk
6// |-- settings/
7// | |-- page.tsx -> settings chunk
8// |-- admin/
9// |-- page.tsx -> admin chunk
10
11// Parallel route loading
12// app/dashboard/layout.tsx
13export default function DashboardLayout({
14 children,
15 analytics,
16 activity
17}: {
18 children: React.ReactNode
19 analytics: React.ReactNode
20 activity: React.ReactNode
21}) {
22 return (
23 <div className="grid grid-cols-3 gap-4">
24 <div className="col-span-2">{children}</div>
25 <div className="space-y-4">
26 {analytics}
27 {activity}
28 </div>
29 </div>
30 )
31}
32
33// app/dashboard/@analytics/page.tsx
34// app/dashboard/@activity/page.tsx
35// These load in parallel, each as separate chunksUsage Instructions#
- Use
next/dynamicfor components that aren't needed immediately - Set
ssr: falsefor client-only components (maps, charts) - Use Intersection Observer for below-the-fold content
- Implement virtual lists for large datasets
- Preload on user intent for better perceived performance
Best Practices#
- Analyze first - Use bundle analyzer to find heavy components
- Critical path - Don't lazy load above-the-fold content
- Loading states - Always provide meaningful fallbacks
- Prefetch strategically - Balance bandwidth vs speed
- Virtual lists - Use for lists with >100 items
- Image optimization - Use Next.js Image with lazy loading
- Monitor impact - Track Core Web Vitals improvements
Related Patterns#
- Bundle Analysis - Identify heavy imports
- Caching - Cache loaded resources
- Optimization - General optimizations
- Code Splitting - Split bundles