You add "sideEffects": false to your library’s package.json to unlock tree-shaking for your JavaScript exports — and on the next production build your consumers run, every stylesheet your package ships has silently vanished. No error, no warning, just unstyled components. This page walks through exactly why that happens and how to scope the flag precisely so CSS and asset imports survive while dead JavaScript code is still eliminated.


Root Cause Explanation

The sideEffects field is documented in Implementing the sideEffects Flag Correctly, but the CSS failure mode deserves its own treatment because it is completely silent.

When "sideEffects": false is set globally, bundlers (Webpack 5, Rollup, esbuild) treat every import in your package as a pure computation: if the imported binding is never referenced, the import can be discarded. CSS imports import nothing — they are executed purely for their side effect of registering styles. Because the bundler sees no consumed export, the import is marked dead code and dropped from the output graph. The same logic applies to import './polyfills/resize-observer.js' and import './assets/reset.css'.

The failure is invisible at the module-resolution stage and only manifests at render time: the DOM exists, the JavaScript runs, but the stylesheet was never included in the bundle.

How sideEffects: false drops CSS imports A flow diagram showing the bundler's module graph analysis. With sideEffects false, a CSS import has no consumed export, so it is marked as dead code and removed. With a scoped glob array, the CSS path matches the glob and is retained. sideEffects: false (global) sideEffects: ["./dist/**/*.css"] import './styles/base.css' No consumed export sideEffects: false → drop CSS eliminated unstyled components import './styles/base.css' Path matches glob marked side-effectful → keep CSS preserved JS still tree-shaken

Minimal Reproduction

The smallest package.json that triggers the issue — no CSS framework, no component library, just a package that ships a stylesheet:

{
  "name": "my-ui-kit",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./styles": "./dist/styles/base.css"
  },
  "sideEffects": false
}

A consumer imports the stylesheet directly:

// consumer/src/app.ts
import 'my-ui-kit/styles';   // ← this import is silently dropped
import { Button } from 'my-ui-kit';

With "sideEffects": false, Webpack 5 and Rollup both discard the import 'my-ui-kit/styles' line because no exported binding from that path is consumed. The Button import survives because its export is referenced.


Step-by-Step Fix

Step 1 — Replace the boolean with a scoped glob array

Before:

{
  "sideEffects": false
}

After:

{
  "sideEffects": [
    "./dist/styles/*.css",
    "./dist/styles/**/*.css",
    "./dist/assets/**/*.{png,svg,woff2,woff,ttf}"
  ]
}

Every path listed in the array is treated as side-effectful. Every path not listed remains eligible for tree-shaking. JavaScript modules in ./dist/ that are not named here will still be analyzed for dead-code elimination.

Step 2 — Align glob prefixes with your actual published paths

Globs are resolved against the installed package root, not the project root. Inspect what gets published before committing to a prefix:

npm pack --dry-run

Sample output:

npm notice 📦  [email protected]
npm notice === Tarball Contents ===
npm notice 1.2kB  dist/index.js
npm notice  890B  dist/index.js.map
npm notice  440B  dist/styles/base.css
npm notice  210B  dist/assets/logo.svg

The paths in the tarball are dist/styles/base.css and dist/assets/logo.svg. Your glob must start with ./dist/ — not ./src/, not ./styles/ at the root.

Step 3 — Guard against polyfill and patch imports

Any import that runs code for its DOM or runtime effect — not for an exported value — must also appear in the array:

{
  "sideEffects": [
    "./dist/styles/**/*.css",
    "./dist/polyfills/resize-observer.js",
    "./dist/polyfills/custom-elements.js",
    "./dist/assets/**/*.{png,svg,woff2}"
  ]
}

If your polyfill directory grows, use a directory glob: "./dist/polyfills/**/*.js". Be aware this opt-out covers every file under that path — do not use it for directories that also contain regular utility modules you want shaken.

Step 4 — Verify TypeScript declaration files are excluded

TypeScript does not read sideEffects — it is a bundler-only hint. Glob patterns that accidentally match .d.ts files are harmless for TypeScript itself, but they cause some bundler configurations to treat declaration files as side-effectful, bloating the final bundle with map/type data.

{
  "sideEffects": [
    "./dist/styles/*.css"
  ]
}

This pattern matches dist/styles/base.css but not dist/styles/base.d.ts because .css is explicit. Never use **/* without an extension filter.


Verification Command

After updating package.json, confirm the CSS files survive a production Webpack build:

npx webpack --config webpack.config.js --mode production --stats-children 2>&1 | grep -E '\.(css|svg|woff)'

Expected output (CSS chunk present):

assets/styles/base.css    2.1 KiB   [emitted]

If the grep returns nothing, the stylesheet was eliminated. Double-check that the glob in sideEffects matches the path reported by npm pack --dry-run.

For Rollup-based builds, inspect the output directory directly:

npx rollup -c rollup.config.ts && ls dist/styles/

If dist/styles/ is empty or missing, Rollup dropped the style entry. Confirm your Rollup config feeds the CSS file through a plugin (e.g. @rollup/plugin-postcss) and that the sideEffects entry matches the output path, not the source path.

For Vite in library mode, also set:

// vite.config.ts
export default defineConfig({
  build: {
    lib: { /* … */ },
    cssCodeSplit: false   // emit a single CSS file, not per-chunk fragments
  }
})

Without cssCodeSplit: false, Vite may emit orphaned style chunks that consumers need to import separately — an API contract your users will not expect.


Edge Cases / Gotchas

  • Bare glob without ./ prefix. A pattern like "styles/*.css" is not guaranteed to match nested paths in all bundler glob implementations. Always prefix with ./.
  • pnpm symlinked node_modules. pnpm stores packages in a content-addressable cache and symlinks them under node_modules. Bundler glob resolution walks the symlink, so the effective path is the same. No special handling needed, but verify by running ls node_modules/my-ui-kit/dist/styles/ to see the resolved structure.
  • esbuild does not read sideEffects by default. You must pass --bundle and the consumer must be running esbuild with sideEffects: true in its plugin configuration, or the package-level hint is ignored entirely. This is a known esbuild limitation.
  • CSS Modules vs plain CSS. CSS Modules (files with .module.css) are typically imported for their exported class-name map — import styles from './Button.module.css'. The imported styles object is a value, so the import is retained even under sideEffects: false. You only need the glob for plain CSS imports that export nothing.
  • Vite dev server vs production build. Vite’s dev server does not apply tree-shaking, so your CSS will appear fine in development even with a broken sideEffects configuration. The failure only surfaces during vite build or a downstream Webpack/Rollup consumer build.
  • Glob brace expansion support. The {png,svg,woff2} brace syntax is supported by Webpack 5 and Rollup but is not standard POSIX glob. If you encounter issues, list each extension as a separate entry instead of relying on brace expansion.
  • Scoped packages and hoisting. When your package is scoped (@org/my-ui-kit), glob resolution behavior is unchanged — bundlers resolve relative to the package root regardless of scope. No adjustment needed.

FAQ

Why does sideEffects: false drop my CSS even when the import is explicit?

Because the bundler’s side-effect analysis looks at whether the imported binding is consumed, not whether the import string is present. import 'my-ui-kit/styles' imports no binding; the bundler marks the module reachable but side-effect-free, then prunes it from the output when sideEffects: false is set globally. Adding the path to the sideEffects array overrides this decision for that specific file.

My glob matches in testing but CSS is still dropped in production — why?

The two most common causes are: (1) the glob was written against the source path (./src/styles/) but the bundler compares it to the published path (./dist/styles/); and (2) the package was re-packed or rebuilt since you last checked, changing the output layout. Run npm pack --dry-run again and diff the paths against your glob entries.

Can I set sideEffects: true in the subfolder’s own package.json?

Webpack 5 supports per-module sideEffects override through a nested package.json inside a subdirectory of the package. However, this only works when the subdirectory is explicitly published (i.e. appears in the tarball as dist/styles/package.json) and only in Webpack 5. It is not supported by Rollup or esbuild. The root-level glob array is more portable.

Does listing a file in sideEffects prevent it from being code-split?

No. The sideEffects array tells the bundler not to drop the file; it does not affect how the bundler splits or merges chunks. A CSS file listed in sideEffects can still be extracted into a separate chunk by MiniCssExtractPlugin (Webpack) or postcss (Rollup/Vite).

Do I need sideEffects entries for CSS imported via @import inside another CSS file?

No. CSS-to-CSS @import is resolved by the CSS loader/plugin (e.g. postcss-import, css-loader) before the bundler’s JavaScript module graph is built. The bundler never sees those @import statements as JavaScript module edges, so sideEffects has no effect on them.



Up: Implementing the sideEffects Flag Correctly