Configuring sideEffects for CSS and Asset Imports
Configure package.json sideEffects glob patterns to preserve CSS, font, and polyfill imports while enabling aggressive dead code elimination for JavaScript modules.
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.
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 runningls node_modules/my-ui-kit/dist/styles/to see the resolved structure. - esbuild does not read
sideEffectsby default. You must pass--bundleand the consumer must be running esbuild withsideEffects: truein 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 importedstylesobject is a value, so the import is retained even undersideEffects: 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
sideEffectsconfiguration. The failure only surfaces duringvite buildor 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.
Related
- Implementing the sideEffects Flag Correctly — the full migration from a global boolean to a scoped array, with bundler-specific notes for Webpack, Rollup, and Vite.
- Advanced Dead-Code Elimination Techniques — goes beyond
sideEffectsto cover pure annotations,/*#__PURE__*/comments, and module graph hints. - Why Barrel Files Break Tree-Shaking in Webpack — a companion failure mode where re-export patterns override
sideEffectsanalysis.