// LOADING
// LOADING
// LOADING_ARTICLE
A large JavaScript bundle is a silent performance tax. Every extra kilobyte delays parsing, execution, and interactivity—particularly on mid-range mobile devices. Next.js gives you several tools to control bundle size, but you have to reach for them intentionally. Here's a systematic approach.
Before optimizing, you need to know what you're shipping. @next/bundle-analyzer wraps webpack's built-in analyzer and renders an interactive treemap.
bashnpm install --save-dev @next/bundle-analyzer
ts// next.config.ts import bundleAnalyzer from '@next/bundle-analyzer'; import type { NextConfig } from 'next'; const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true', }); const config: NextConfig = {}; export default withBundleAnalyzer(config);
bashANALYZE=true next build
The treemap opens automatically in your browser. Look for:
client.js but is only used on the serverNext.js automatically splits code at the route boundary—each page gets its own chunk. But within a page, all statically imported components ship in the same bundle. Use dynamic() to defer non-critical UI.
tsximport dynamic from 'next/dynamic'; // Only loaded when the modal is actually opened const CreateBlogModal = dynamic( () => import('@/components/pages/Blogs/CreateBlogModal'), { ssr: false } // skip server render for heavy client-only components ); export default function BlogsPage() { const [open, setOpen] = React.useState(false); return ( <> <button onClick={() => setOpen(true)}>New Post</button> {open && <CreateBlogModal onClose={() => setOpen(false)} />} </> ); }
Good candidates for dynamic(): rich text editors, charting libraries, map embeds, code editors, and any component behind a user interaction. This connects well with Code Splitting and Lazy Loading in Next.js if you want deeper coverage of route-level strategies.
Tree shaking removes unused exports, but it only works when you import named exports—not the entire module.
ts// Bad — pulls in the entire lodash library (~70 KB) import _ from 'lodash'; const result = _.groupBy(items, 'category'); // Good — only the groupBy function (~5 KB) import groupBy from 'lodash/groupBy'; const result = groupBy(items, 'category');
For icon libraries like lucide-react, named imports already tree-shake correctly in modern bundlers. Where they don't, use individual file imports or configure modularizeImports in next.config.ts:
tsconst config: NextConfig = { modularizeImports: { '@mui/icons-material': { transform: '@mui/icons-material/{{member}}', }, }, };
With the App Router, any component that doesn't need interactivity can stay on the server and contribute zero bytes to the client bundle. Audit your components:
useState, useEffect, event handlers, or browser APIs? It needs 'use client'.This is the highest-leverage technique available in Next.js 13+. A data-fetching wrapper around a table that used to ship as a client component can become zero client bytes. See React Server Components in the App Router for a detailed treatment.
Run npx bundlephobia <package-name> before adding any dependency. Some packages that look small have deep trees:
moment — ~67 KB minified+gzipped. Use date-fns (imports per-function) or the native Intl API.axios — ~13 KB. The native fetch covers most use cases.uuid — for browser use, crypto.randomUUID() is built-in.For packages you can't replace, check if a lighter alternative exists. zod is already fairly lean, but if validation only runs server-side, keep it out of any 'use client' boundary.
Fonts loaded with @font-face in CSS bypass Next.js optimization. Use next/font to inline font CSS and prevent layout shifts without adding extra network requests:
tsximport { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], display: 'swap', });
For third-party scripts (analytics, chat widgets), use next/script with the right strategy:
tsximport Script from 'next/script'; // afterInteractive: loads after hydration — good for analytics <Script src="https://example.com/analytics.js" strategy="afterInteractive" /> // lazyOnload: loads during idle time — good for chat widgets <Script src="https://cdn.chat.io/widget.js" strategy="lazyOnload" />
strategy="beforeInteractive" blocks the page and should rarely be used.
Next.js transpiles based on your browserslist config (or its defaults). If you're targeting modern browsers only, you can skip many polyfills:
# .browserslistrc
> 0.5%
not dead
not IE 11
last 2 Chrome versions
last 2 Firefox versions
last 2 Safari versions
Run npx browserslist to see which browsers your current config targets. Every feature you can remove from the polyfill set is a few more kilobytes saved.
Always measure bundle size with next build, not next dev. Development mode deliberately skips optimizations to keep rebuilds fast and will show a much larger bundle than your users actually download.
For CI enforcement, track bundle size over time using the bundlesize npm package or GitHub Actions with size-limit:
bashnpx size-limit
This pairs naturally with a solid CI/CD pipeline for Next.js that gates deploys on bundle budgets.
Bundle size is a compound problem—there's rarely one giant fix. Work through the list:
dynamic()next/scriptYou can see how these techniques interact with other performance wins in Image Optimization in Next.js and the broader Core Web Vitals guide. Check out the projects page to see these optimizations applied to real applications.