DEV Community

Nainik Mehta
Nainik Mehta

Posted on

Cut React bundle size 3x — No Next.js migration required

Stop Migrating to Next.js for Performance

Hot take: you can often reduce React bundle size without Next.js. Before scheduling a multi-week migration, try five surgical fixes you can apply on Vite/Rollup in an afternoon. These are low-risk, production-ready, and used successfully by teams that cut main bundles by 2–5×.

Why this matters

Large initial bundles hurt Time to Interactive (TTI) and conversion on mobile. Next.js offers convenient automatic optimizations, but it’s not the only path to smaller client payloads. If your goal is to reduce React bundle size without Next.js, measure first and then apply targeted fixes.

1) Isolate heavy libraries with dynamic imports

If a dependency is large but only used for one feature (PDF export, rich text, Monaco), push it behind a lazy boundary so it only downloads on demand. Vite/Rollup will emit a separate chunk for dynamic import() calls.

Example (React.lazy + Suspense):

import { lazy, Suspense } from 'react';

const PDFViewer = lazy(() => import('@react-pdf/renderer'));

export default function Report() {
  return (
    <div>
      <h1>Report</h1>
      <Suspense fallback={<div>Loading PDF viewer…</div>}>
        <PDFViewer />
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Rules of thumb:

  • Confirm no stray top-level imports of that library elsewhere — a single eager import can pull it back into the main bundle.
  • Lazy-load at the route or user-action boundary (click, open, focus) to match intent.
  • Cache the module if you need repeated synchronous use after the first load.

2) Replace high-severity dependencies (lodash, moment)

Some libraries are simply heavy or ship non-tree-shakeable builds. Two quick wins:

  • Replace lodash (barrel/commonjs) with lodash-es named imports or native alternatives.
  • Replace moment with dayjs or date-fns (or use the native Temporal API where available).

Each swap commonly saves ~15–25 KB gzipped or more per occurrence. Before adding anything new, check Bundlephobia and run a visualizer to see the gzipped impact after tree-shaking.

3) Use conservative tree-shaking + .server.ts separation

Vite/Rollup tree-shake works best when code is authored as ESM and packages set sideEffects correctly. Two practical steps:

  • In vite.config.ts set moduleSideEffects or ensure libraries have "sideEffects": false in their package.json so Rollup can drop init-only code:
// vite.config.ts (excerpt)
export default defineConfig({
  build: {
    rollupOptions: {
      treeshake: true,
    },
    moduleSideEffects: false,
  },
});
Enter fullscreen mode Exit fullscreen mode
  • Move server-only helpers into files with a .server.ts suffix (or otherwise ensure they aren’t imported in client entry points). This is a tiny structural change that can eliminate megabytes when a shared file drags in a server-only dependency.

Example: move token-counting or file-system helpers from shared.ts -> shared.server.ts so the client bundle no longer includes a tokenizer WASM or other server-only code.

4) Vendor splitting and manualChunks

Control what stays in the initial payload by splitting vendors into logical groups. ManualChunks in Rollup/Vite gives you surgical control over caching and initial downloads.

Example manualChunks in vite.config.ts:

build: {
  rollupOptions: {
    output: {
      manualChunks(id) {
        if (id.includes('node_modules')) {
          if (id.includes('react')) return 'vendor-react';
          if (id.includes('echarts') || id.includes('chart.js')) return 'vendor-charts';
          if (id.includes('@react-pdf') || id.includes('pdfjs-dist')) return 'vendor-pdf';
          return 'vendor-others';
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Why it helps: vendor chunks that change rarely stay cached. Your app code can change weekly without invalidating a 200–400 KB React/chart/pdf vendor file that users already have cached.

Caveats:

  • Don’t over-split React internals — putting React and libraries that tightly couple to it in separate chunks can cause subtle runtime/hydration issues. Keep React and ReactDOM together.
  • Avoid creating many tiny chunks; aim for a sweet spot of ~50–500 KB gzipped per chunk.

5) Reconsider heavy state libraries

Redux and RTK are great for many apps but they carry a footprint. For many single-page apps, lighter options like Zustand, jotai, or targeted atom/Context patterns reduce bundle size and improve TTI on low-end devices.

A practical approach:

  • Prototype a migration of a single domain (form state, local UI state) to Zustand to measure size and runtime cost.
  • Keep server sync and dev tooling needs in mind — RTK’s devtools + middleware are convenient and sometimes worth the trade-off.

Many teams find swapping to a smaller state library or adopting a mixed approach (Zustand for local UI, RTK for shared API cache) yields immediate payload wins.

Measurement, safety, and deployment

Always measure. The fastest route to impact is:

  1. Run a bundle visualizer (rollup-plugin-visualizer, vite-bundle-visualizer).
  2. Identify the top 3 biggest slices by gzipped size.
  3. Apply one change (e.g., lazy-load a heavy library) and rebuild.
  4. Verify the chunk graph and gzipped deltas.

Add size budgets to CI (size-limit, bundlesize) so future changes are noticed early.

When to consider Next.js anyway

If you need server rendering, server components, or built-in streaming and you’re building a large content site where SSR provides SEO/first-byte gains, Next.js remains a solid choice. The point here is: if your blocker is bundle size alone, the five React-only moves above are cheaper, lower-risk, and can deliver the same main-bundle wins.

Conclusion — small changes, big wins

A framework migration is a big investment. In 2026, with modern bundlers, careful tree-shaking, manualChunks, and runtime lazy-loading, you can often reduce React bundle size without Next.js — sometimes dramatically. Start with measurement, apply these five surgical fixes, and save the migration for problems that truly require it.

Which of these would you try first in your app?

Top comments (0)