We help companies like yours with expert Next.js performance advice and auditing.

nextjs, optimization
29/08/2025 15:24

When to Use optimizePackageImports vs modularizeImports - and the Gotchas

Avoid barrel-file bloat - when each Next.js import optimizer shines (and breaks)

media

Next.js bundles JavaScript and CSS to serve pages quickly in development and production. Tree‑shaking is the mechanism modern bundlers use to discard unused code at build time, but barrel files – modules that re‑export hundreds of symbols – can defeat tree‑shaking because the bundler is forced to include the entire barrel in the client bundle. Component libraries (@mui/material, react-icons, date‑fns, etc.) often use barrel files for convenience, so naive imports such as import from '@mui/material' end up shipping the entire library to the browser.

Next.js introduced two compiler options to address this problem:

  • modularizeImports, available since Next.js 13.1.
  • optimizePackageImports, introduced as an experimental feature in Next.js 13.5

Both features aim to mitigate the cost of barrel files, but they differ significantly in use‑cases and limitations.

TL;DR

  • optimizePackageImports: automatic deep‑splitting for bare package imports; low maintenance; skips anything it can’t prove safe.
  • modularizeImports: explicit, pattern‑based rewrites for app aliases/relative barrels or when you need to redirectto other packages; deterministic and auditable.
  • These are not tree‑shaking; they just point imports at more precise files. Use both together: enable optimizePackageImports, then add modularizeImports where auto misses.

What optimizePackageImports actually does

At build time, Next enables an SWC transform with an allow-list of package names (the ones you specify in experimental.optimizePackageImports, plus some defaults). For import lines like:

import { Button, Checkbox as C } from '@mui/material'

It:

  • #1 Scan the package entry point: When the compiler encounters import { X, Y } from '@package', it reads the package’s main file (e.g. index.js or index.ts) and examines all named exports.
  • #2 Follow re‑exports: For each exported symbol, it follows export * from statements and maps each symbol to its final source file. It handles nested re‑exports and wildcard patterns so you do not have to define templates yourself.
  • #3 Rewrite the import: The compiler emits import X from '@package/actual-file.js' for each named import, ensuring that only the used modules are loaded at runtime.

‎ ‎ ‎ ‎ ‎ ‎ ‎

import Button from '@mui/material/Button'
import C from '@mui/material/Checkbox'

With this approach, Next.js can optimize libraries whose internal structure you may not know. You simply specify the package name, and the transform conservatively rewrites imports it can prove safe.

// next.config.js
module.exports = {
  experimental: {
    optimizePackageImports: [
      '@phosphor-icons/react',
      '@radix-ui/react-icons',
      'validator',
      '@mantine/core',
    ],
  },
};

Limitations (and why a line might not be optimized)

Package imports only

The transform only considers bare package specifiers like '@mui/material', 'date-fns', 'rxjs'. It does not look at:

  • Alias imports like @/lib, @/components/Button,
  • Relative imports like ./index or ../components.

For first-party code, use modularizeImports to map your aliases which we’ll discuss in depth below. If you want the auto optimizer for your own code, publish it as a package and import it as a package (e.g., '@your-org/ui'), then add it to the allow-list.

Allow-listed packages only

Only packages in the configured list (plus any framework defaults) are considered. See the defaults here.

Static imports only

The transform applies to static import declarations. It does not rewrite require(...) calls or runtime import('pkg'); use static, named imports for optimization.

No debug mode for skipped imports

There is no built-in report listing which imports were not optimized. Skipped lines are left as-is without a compiler log; verify results by inspecting emitted chunks or enabling a bundle analyzer.

Namespace & dynamic imports are left as-is

  • import * as Pkg from 'pkg' requires the entire namespace at runtime. Because your code could read any member of Pkg, the optimizer can’t safely split it into leaf files.
  • import('pkg') runs at runtime. The transform is a static build-time pass and won’t rewrite dynamic imports. Prefer static, named imports when you want optimization:

‎ ‎ ‎ ‎ ‎ ‎

// Good (named, static)
import { addDays } from 'date-fns'
// Not optimized (namespace)
import * as df from 'date-fns'
// Not optimized (dynamic)
const mod = await import('date-fns')

Default imports are conservative

A default import from the package root (e.g., import Foo from 'pkg') often refers to a barrel’s default (which may aggregate multiple files). Unless the transform can prove the default is one leaf file, it won’t rewrite it.

// Stays as-is; default may be an aggregator
import _ from 'lodash-es'
// Already explicit; no rewrite needed
import isEqual from 'lodash-es/isEqual'

Side-effectful barrels cause bailouts

Side‑effectful means the module runs code at import time that changes observable program state (e.g., mutating globals, registering polyfills, attaching properties to exports/module.exports, touching the DOM, injecting CSS, or branching on process.env). If a barrel’s export surface depends on those side effects or on evaluation order, the optimizer can’t safely determine a unique leaf per symbol, so it leaves the import as‑is. (Note: a module can be side‑effectful and still be rewritten if its exports are declared statically - the real problem is when side effects define or alter exports.)

"Dynamic" barrels cause bailouts

A dynamic barrel defines exports at runtime- for example, by adding properties to the exported object or using getters to lazily require files. Because the export graph isn’t purely static, the optimizer can’t prove a single file per symbol.
Common patterns that block optimization:

// Pattern A: lazy getters
Object.defineProperty(exports, 'X', {
  enumerable: true,
  get: () => require('./x') // runtime getter
})
// Pattern B: attach methods dynamically to an exported object
const api = function () {}
api.Button = require('./button')
api.Card = require('./card')
module.exports = api

Real‑world example: the lodash root import (require('lodash')) exports a function/object and then attaches methods at runtime (e.g., _.chunk = require('./chunk')). From the optimizer’s perspective, that top‑level entry is a dynamic barrel, so it can’t statically map to one ESM leaf. Prefer lodash-es (pure ESM), explicit deep paths like import chunk from 'lodash/chunk', or configure compiler.modularizeImports.

Must map to a single file (per symbol)

For each named specifier you import, the optimizer only rewrites it if it can resolve that symbol to exactly one concrete file inside the package. If it can’t prove that (e.g., multiple candidates or unclear re-exports), it leaves your import unchanged. This does not require one symbol per file-the target file may export multiple symbols, the key is that the symbol maps to one file.

Ambiguity => no rewrite

If multiple files could provide the same symbol via export * chains, or if the transform can’t determine a unique source (e.g., both a.ts and b.ts export Button and index.ts does export * from './a'; export * from './b';), it does not guess. It keeps your original import to avoid changing semantics or side-effects ordering.

Traversal depth cap

Re-export chains are followed up to a fixed depth (10). Beyond that, the import is left intact.

What modularizeImports actually does

modularizeImports is a pattern-driven SWC transform configured in next.config.js under compiler.modularizeImports. Unlike optimizePackageImports, it does no package analysis. It simply rewrites import sources according to your mapping template.

Config shape (common usage)

// next.config.js
module.exports = {
  compiler: {
    modularizeImports: {
      '@mui/material': { transform: '@mui/material/{{member}}' },
      'date-fns': { transform: 'date-fns/{{member}}' },
      '@/components': { transform: '@/components/{{member}}' },
    },
  },
}

How the transform works

  • #1 Match: For each static import ... from 'source', if 'source' matches a key in modularizeImports (exact string or alias you use), the rule applies.
  • #2 Rewrite named specifiers: For import { X, Y as Z } from 'source', it emits one import per specifier by replacing {{member}} with the imported name (X, Y). Aliases are preserved on the left side.
  • #3 Applies everywhere: Runs in both dev and prod, client and server builds, and it works for first-party aliases/relative barrels as well as third‑party packages.

Get our monthly web performance news roundup!

No spam - everything you need to know about in web performance with exclusive insights from our team.

Before => After

// before (first‑party alias)
import { Card } from '@/components'

// after
import Card from '@/components/Card'

Key differences vs‎ optimizePackageImports

  • You control the map: It will rewrite only what you map, exactly as you map it.
  • No allow-list or analysis: There’s no symbol resolution or export traversal; it doesn’t "discover" files.
  • Works for app code: Ideal for your own aliases/relative barrels where the optimizer won’t run.
  • Can redirect to other packages: You can transform 'react-icons' to '@react-icons/all-files/{{member}}', which the auto optimizer will never do.

Gotchas with modularizeImports

  • Literal source match. Rules match the import string as written. If your code imports the same thing via different sources (e.g., '@/components' and '~/components'), you need a rule for each source.
  • Renamed imports behave as expected. The template uses the imported member name before aliasing. import from 'pkg' maps using {{member}} = Button, and the left-side alias (Btn) is preserved.
  • Default & namespace imports are not modularized. import Foo from 'pkg' and import * as Pkg from 'pkg' aren’t expanded by {{member}}. Import defaults from a deep path directly or refactor to named imports.
  • No verification / missing files break the build. If your template points to a path that doesn’t exist (or changed in a package upgrade), you’ll get a module not found error.
  • Package layout churn requires maintenance. Because mappings are hard-coded, library reorganizations require you to update the rules.
  • Matches before resolver logic. The rule must match the import exactly as it appears in source; it doesn’t "see" what your TS/webpack alias eventually resolves to.
  • Overrides the auto optimizer where it matches. If both modularizeImports and optimizePackageImports could apply, your explicit mapping wins for those sources.

modularizeImports Deprecation note

Modularize Imports - This option has been superseded by optimizePackageImports in Next.js 13.5. We recommend upgrading to use the new option that does not require manual configuration of import paths.

Docs: https://nextjs.org/docs/architecture/nextjs-compiler#modularize-imports

Scenarios: which one to use?

Scenario Use Why Notes
First‑party alias/relative barrels(e.g., @/components, ./ui) modularizeImports Optimizer targets bare specifiers only; aliases/relative paths need explicit mapping. Works in Pages Router and older Next.js.
Third‑party package via bare specifier with static exports (e.g., @mui/material, date-fns) optimizePackageImports Automatic deep‑splitting with low maintenance. Won’t split namespace/default/dynamic imports.
Internal monorepo/UI package imported as bare specifier (e.g., @your-org/ui) PreferoptimizePackageImports It’s a package; add to allow‑list if exports are static. Use modularizeImports if you need strict, frozen mappings.
Redirect to a different package/path(e.g., react-icons@react-icons/all-files/{{member}}) modularizeImports Only manual rules can redirect across packages. Useful for migrations or known better subpaths.
Dynamic or side‑effectful CJS barrels (e.g., lodash root) modularizeImports or deep paths Optimizer can’t statically prove leaves. Prefer ESM variants like lodash-es.
Namespace/default/dynamic imports in your source Refactor Need static named imports for splitting. Or import from explicit deep paths.
Ambiguous export graphs or deep chains (>10) modularizeImports Avoids ambiguity and depth cap. Keep barrels simple to help auto.
Pages Router or Next.js < 13.5 modularizeImports Optimizer may not apply in these setups. Upgrade later to use the optimizer.
Compliance/allow-listing / deterministic builds modularizeImports Explicit, auditable import surfaces. Pin versions; CI check for breakage.
"Mostly works but misses a few" Combine both Auto for broad wins; manual for exceptions. Manual rules override auto where they match.

Bottom line

  • Use optimizePackageImports for low-maintenance wins on clean, third-party (or internal) packages you import by bare specifier.
  • Use modularizeImports when you need guaranteed, explicit control-especially for your own code and for packages with non-trivial barrels.

Practical tips & sanity checks

  • Start with auto, then pin with manual. Enable optimizePackageImports for obvious wins, then add modularizeImports where you see misses.
  • Verify the output. Look for deep import paths in emitted chunks (e.g., @mui/material/Button). If you still see top-level barrels for a package you expected to split, add a manual mapping.
  • Avoid namespace imports (import * as M from 'pkg') in hot paths; they can’t be split by the optimizer.

Get rid of your own barrel files, or keep barrels simple

in your own code. If you want your internal package to benefit from auto optimization, make exports static and one-symbol-per-file.

Get Your Free Consultation Today

Don’t let a slow Next.js app continue to impact your commercial goals. Get a free consultation
with one of our performance engineers and discover how you can achieve faster load times,
happier customers, and a significant increase in your key commercial KPIs.