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-virtual

Code 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 chunks

Usage Instructions#

  1. Use next/dynamic for components that aren't needed immediately
  2. Set ssr: false for client-only components (maps, charts)
  3. Use Intersection Observer for below-the-fold content
  4. Implement virtual lists for large datasets
  5. 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