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

How npm builds the published tarball contents Four sequential steps: start from the full project directory, remove npm's hardcoded excludes like node_modules, apply the files allowlist if present, then apply .npmignore as a further denylist, producing the final tarball contents. Full project directory Remove hardcoded excludes Apply files allowlist Apply .npmignore denylist Final tarball contents

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-run still shows dist/index.test.js after adding a negation pattern.

Root cause: Negation patterns in files only 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-run after 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 .npmignore seems to have no effect — the same files still publish.

Root cause: When files is present in package.json, npm applies .npmignore only within directories already allowlisted by files. It cannot un-exclude a directory that files never included in the first place, and it cannot pull in paths files excludes.

Fix: Decide on one primary mechanism — files for allowlisting, .npmignore for 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-run run inside a pnpm workspace package still reflects that package’s own files field correctly, but workspace-level .npmignore files at the repo root do not apply per-package — each package needs its own if it relies on .npmignore.
  • Yarn (Classic and Berry). yarn pack follows the same files/.npmignore precedence as npm, but Yarn Berry with Plug’n’Play adds a .yarnrc.yml compression step that doesn’t change file selection, only tarball format.
  • Monorepos with a shared tsconfig. If tsconfig.json extends a repo-root config, excluding tsconfig.json via .npmignore is safe — consumers never need it. Only exclude it if nothing in your published dist/**/*.d.ts references it via /// triple-slash directives.
  • Symlinked local development. npm pack --dry-run resolves 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. files patterns are case-sensitive on Linux CI runners even if your local macOS filesystem is not; a directory named Dist locally will not match a files entry of dist once 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.



↑ Back to package.json Fields for Distribution