How I Reduced Bundle Size by 96% in a Next.js Application

February 2025

The Problem

While building the Seismic Risk Information System for Kazakhstan's government infrastructure, I encountered serious performance issues that made the application nearly unusable on slower networks.

Initial metrics:

  • Main bundle: 261kB (gzipped)
  • First Contentful Paint: 3.2s
  • Largest Contentful Paint: 5.8s
  • Time to Interactive: 6.4s
  • Lighthouse Performance Score: 62/100

Government officials in remote areas were experiencing 30+ second load times. This was unacceptable for a system designed to provide critical infrastructure information during emergencies.

Investigation: Finding the Culprits

Using Next.js Bundle Analyzer, I identified the main offenders:

npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({
  // your config
})

What I found:

  1. Mapbox GL JS (180kB) - loaded on every page, even non-map routes
  2. Chart.js (75kB) - entire library imported for simple bar charts
  3. date-fns (30kB) - full library for 2 date formatting functions
  4. Lodash (24kB) - importing everything instead of specific utilities

These four dependencies alone accounted for 309kB—more than the entire target bundle size.

Solution 1: Dynamic Imports for Heavy Components

The biggest win came from lazy-loading Mapbox components only on pages that needed them.

// ❌ Before: Static import
import Map from '@/components/Map'

function Dashboard() {
  return (
    <div>
      <Map data={locations} />
    </div>
  )
}
// ✅ After: Dynamic import with loading state
import dynamic from 'next/dynamic'

const Map = dynamic(() => import('@/components/Map'), {
  loading: () => <MapSkeleton />,
  ssr: false // Mapbox requires browser APIs
})

function Dashboard() {
  return (
    <div>
      <Map data={locations} />
    </div>
  )
}

Result: 180kB moved out of main bundle ✅

The map component now loads only when needed, and users see a skeleton loader during the async import.

Solution 2: Tree-Shakeable Imports

Most libraries support tree-shaking, but you need to import functions individually.

// ❌ Before: Importing entire libraries
import _ from 'lodash'
import { format } from 'date-fns'
import Chart from 'chart.js'

_.debounce(handleSearch, 300)
// ✅ After: Specific imports
import debounce from 'lodash/debounce'
import format from 'date-fns/format'
import { BarController, BarElement, CategoryScale } from 'chart.js'

debounce(handleSearch, 300)

Results:

  • Lodash: 24kB → 2kB (91% reduction)
  • date-fns: 30kB → 3kB (90% reduction)
  • Chart.js: 75kB → 15kB (80% reduction)

Solution 3: Route-Based Code Splitting

Next.js automatically splits code by route, but I optimized further by splitting large dashboard components.

// pages/dashboard.js
const Analytics = dynamic(() => import('@/components/Analytics'))
const Reports = dynamic(() => import('@/components/Reports'))
const Settings = dynamic(() => import('@/components/Settings'))

export default function Dashboard() {
  return (
    <Tabs>
      <TabPanel name="analytics">
        <Analytics />
      </TabPanel>
      <TabPanel name="reports">
        <Reports />
      </TabPanel>
      <TabPanel name="settings">
        <Settings />
      </TabPanel>
    </Tabs>
  )
}

Each tab's content now loads only when the user switches to it.

Solution 4: Image Optimization

// ❌ Before: Regular img tags
<img src="/logo.png" width={200} height={100} />
// ✅ After: Next.js Image component
import Image from 'next/image'

<Image 
  src="/logo.png" 
  width={200} 
  height={100}
  placeholder="blur"
  priority={false}
  quality={85}
/>

Next.js Image component automatically:

  • Serves optimized formats (WebP, AVIF)
  • Implements lazy loading
  • Prevents layout shift with blur placeholders

Solution 5: Font Optimization

// next.config.js
module.exports = {
  optimizeFonts: true,
}
/* Use font-display: swap to prevent FOIT */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: swap;
}

This ensures text remains visible while custom fonts load.

Results

Bundle Size

| Metric | Before | After | Improvement | |--------|--------|-------|-------------| | Main bundle | 261kB | 10.8kB | 96% ⬇️ | | Map chunk | - | 180kB | Lazy loaded ✅ | | Charts chunk | - | 15kB | On-demand ✅ |

Performance Metrics

| Metric | Before | After | Improvement | |--------|--------|-------|-------------| | First Contentful Paint | 3.2s | 0.9s | 72% ⬇️ | | Largest Contentful Paint | 5.8s | 1.7s | 71% ⬇️ | | Time to Interactive | 6.4s | 2.1s | 67% ⬇️ | | Lighthouse Score | 62 | 95 | 53% ⬆️ |

Real User Impact

After deployment:

  • 25% decrease in bounce rate
  • 40% increase in pages per session
  • Positive feedback from field workers on 3G networks
  • Government officials could now access the system from remote areas

Key Takeaways

  1. Measure before optimizing - Use bundle analyzer to identify actual bottlenecks, don't guess.

  2. Dynamic imports are powerful - Lazy load heavy components that aren't needed immediately. The initial load time improvement is worth the small delay when components load.

  3. Import only what you need - Tree-shakeable imports can reduce bundle size by 80-90% for large libraries.

  4. Code splitting by route AND by component - Next.js handles routes automatically, but strategic component splitting improves perceived performance.

  5. Images matter - Use Next.js Image component for automatic optimization and lazy loading.

  6. Monitor continuously - Set up performance budgets in CI/CD to catch regressions early:

// next.config.js
module.exports = {
  experimental: {
    performanceBudget: {
      maxSize: 50 * 1024, // 50kB for main bundle
    },
  },
}

Tools & Resources

Analysis:

Optimization:

Monitoring:

What's Next?

In my next article, I'll dive into optimizing 500MB+ GeoJSON datasets for Mapbox, where I achieved similar performance improvements through vector tiles and clustering techniques.

Have questions about bundle optimization? Found this helpful? Let me know on LinkedIn or GitHub.


This optimization was implemented for the Seismic Risk Information System, a government infrastructure platform serving 500+ officials across Kazakhstan.


© 2025 Arlen. All rights reserved.