The files Field and Controlling npm pack
Control exactly what ships in your npm tarball with the files field, .npmignore, and npm pack --dry-run so consumers get dist output and nothing extra.
Without a files field, npm publish ships everything in your project directory that isn’t covered by npm’s small built-in ignore list — source maps you didn’t mean to publish, .env.example files, test fixtures, CI config, and sometimes an entire src/ directory that duplicates your built dist/ output. The fix is to make the distribution fields explicit about what ships, using files, .npmignore, and npm pack --dry-run to verify before every publish.
Root Cause
npm publish builds its file list by walking the project directory and applying, in order: npm’s hardcoded exclusions (node_modules, .git, .npmrc, and a few others that can never be included), then the files allowlist if present, then .npmignore (or .gitignore if no .npmignore exists) as a further denylist within whatever files already allowed. If files is absent, npm falls back to publishing the entire directory tree minus its hardcoded exclusions and whatever .npmignore/.gitignore removes — which is fragile because it depends on .gitignore entries that were written for version control, not for tarball contents, and frequently diverge over time.
Minimal Reproduction
A typical project without a files field:
my-package/
├── src/
│ └── index.ts
├── dist/
│ ├── index.js
│ └── index.d.ts
├── test/
│ └── index.test.ts
├── .env.example
├── tsconfig.json
├── package.json
└── README.md
{
"name": "my-package",
"version": "1.0.0",
"main": "./dist/index.js"
}
npm pack --dry-run
npm notice 📦 [email protected]
npm notice Tarball Contents
npm notice 1.0kB src/index.ts
npm notice 2.1kB dist/index.js
npm notice 0.4kB dist/index.d.ts
npm notice 1.8kB test/index.test.ts
npm notice 0.1kB .env.example
npm notice 0.3kB tsconfig.json
npm notice 0.5kB package.json
npm notice 1.2kB README.md
npm notice === Tarball Details ===
Every one of src/, test/, .env.example, and tsconfig.json reaches consumers even though only dist/ is needed at runtime.
SVG: How files, .npmignore, and Hardcoded Rules Combine
Step-by-Step Fix
Step 1 — Add an explicit files allowlist
{
"name": "my-package",
"version": "1.0.0",
"main": "./dist/index.js",
+ "files": [
+ "dist"
+ ]
}
Expected result: npm pack --dry-run now lists only dist/** plus npm’s always-included files.
Step 2 — Exclude test artifacts inside dist with negation patterns
If your build emits test files alongside source (common with tsc compiling a whole src/ tree), exclude them explicitly:
{
"files": [
"dist",
"!dist/**/*.test.js",
"!dist/**/*.test.d.ts",
"!dist/**/__tests__/**"
]
}
Expected result: .test.js and .test.d.ts files inside dist/ are removed from the tarball even though dist itself is allowlisted.
HAZARD PREVENTION
Symptom:
npm pack --dry-runstill showsdist/index.test.jsafter adding a negation pattern.Root cause: Negation patterns in
filesonly re-exclude paths that were included by a broader pattern earlier in the same array — pattern order matters, and a later un-negated pattern can re-include a path.Fix: Place negation patterns immediately after the broad pattern they refine, and re-run
npm pack --dry-runafter every change to confirm.
Step 3 — Add a .npmignore for anything files can’t express cleanly
.npmignore is useful when you want to publish from the project root without an allowlist, or need denylist patterns nested per-directory:
# .npmignore
*.test.ts
*.test.js
.env*
tsconfig.json
.github/
coverage/
HAZARD PREVENTION
Symptom: Adding
.npmignoreseems to have no effect — the same files still publish.Root cause: When
filesis present inpackage.json, npm applies.npmignoreonly within directories already allowlisted byfiles. It cannot un-exclude a directory thatfilesnever included in the first place, and it cannot pull in pathsfilesexcludes.Fix: Decide on one primary mechanism —
filesfor allowlisting,.npmignorefor fine-tuning inside it — rather than expecting the two to independently control disjoint parts of the tree.
Step 4 — Verify with npm pack --dry-run before every publish
npm run build && npm pack --dry-run
npm notice 📦 [email protected]
npm notice Tarball Contents
npm notice 2.1kB dist/index.js
npm notice 0.4kB dist/index.d.ts
npm notice 0.5kB package.json
npm notice 1.2kB README.md
npm notice === Tarball Details ===
npm notice name: my-package
npm notice version: 1.0.0
npm notice package size: 1.8 kB
npm notice unpacked size: 4.2 kB
npm notice total files: 4
Verification Command
# Confirm exact tarball contents without publishing
npm pack --dry-run
# Produce the real tarball locally for inspection
npm pack
tar -tzf my-package-*.tgz
# Cross-check against the exports map so every path referenced there is included
npx publint --strict
Expected publint output when files and exports agree:
✓ All paths in "exports" are included by "files"
✓ No issues found
Edge Cases / Gotchas
- pnpm workspaces.
npm pack --dry-runrun inside a pnpm workspace package still reflects that package’s ownfilesfield correctly, but workspace-level.npmignorefiles at the repo root do not apply per-package — each package needs its own if it relies on.npmignore. - Yarn (Classic and Berry).
yarn packfollows the samefiles/.npmignoreprecedence as npm, but Yarn Berry with Plug’n’Play adds a.yarnrc.ymlcompression step that doesn’t change file selection, only tarball format. - Monorepos with a shared tsconfig. If
tsconfig.jsonextends a repo-root config, excludingtsconfig.jsonvia.npmignoreis safe — consumers never need it. Only exclude it if nothing in your publisheddist/**/*.d.tsreferences it via///triple-slash directives. - Symlinked local development.
npm pack --dry-runresolves through symlinks in workspace setups (npm link, pnpm workspaces), so always run it from the package’s own directory, not a linked consumer, to see the real allowlist. - Case sensitivity.
filespatterns are case-sensitive on Linux CI runners even if your local macOS filesystem is not; a directory namedDistlocally will not match afilesentry ofdistonce published from a case-sensitive CI environment.
Frequently Asked Questions
Does files replace the need for .npmignore?
Mostly, yes. files is an allowlist and .npmignore is a denylist; when both exist, files takes precedence and .npmignore is only consulted for further excluding paths inside an allowlisted directory. Most modern packages only need files.
Which files does npm always include regardless of the files field?
package.json, the README (any case/extension), the LICENSE or LICENCE file, and the file named in the main field are always included even if files omits them. package.json itself cannot be excluded.
Why is node_modules missing from my tarball even though I didn’t exclude it?
npm always excludes node_modules, .git, and a handful of other paths (like .npmrc and lock files other than package-lock.json used for publishing) regardless of files or .npmignore content — these are hardcoded ignores.
Does npm pack --dry-run actually build my package first?
No. It only reports what would be included based on the current state of the filesystem. If you haven’t run your build step, --dry-run will show a missing or stale dist directory. Always run your build immediately before --dry-run.
Do negation patterns in files work the same as in .gitignore?
Similarly but not identically. A leading ! in a files array entry re-includes a path excluded by an earlier broader entry, following the same minimatch glob library gitignore uses, but files entries are always relative to the package root, unlike .gitignore which can be nested per-directory.
Related
- package.json Fields for Distribution — how
filesfits alongsidemain,exports, andsideEffects. - main, module, and exports Field Precedence — make sure every path
filesallowlists is also correctly referenced by your entry fields. - Mastering the package.json Exports Field — the
exportsmap paths thatfilesmust not accidentally exclude.