As a JavaScript or TypeScript project grows, package.json rarely stays clean on its own. Teams add packages for quick experiments, switch libraries during refactors, inherit transitive dependencies through package hoisting, and move code around without always removing what is no longer used.
That clutter is not just cosmetic. Unused dependencies keep vulnerable packages in your install tree even after your code stops importing them. Ghost dependencies, also called phantom or unlisted dependencies, create the opposite problem: your code imports a package that is not declared in package.json, usually because it is being exposed through another dependency.
Both states make your dependency graph harder to trust. They increase CI noise, create fragile builds, and can widen your JavaScript supply chain attack surface. In this article, we’ll look at what unused and ghost dependencies are, why package managers do not fully solve the problem, and how to use Knip to find and manage them across a real project.
| Dependency issue | What it means | Why it matters | How Knip helps |
|---|---|---|---|
| Unused dependency | A package is listed in package.json but no longer used by the project |
Keeps unnecessary packages, vulnerabilities, install cost, and maintenance noise in your dependency graph | Reports dependencies that appear in package.json but are not referenced by the project |
| Ghost dependency | A package is imported in your code but not listed as a direct dependency | Creates fragile builds because the package is only available through another dependency or workspace leak | Reports unlisted dependencies that your code uses but package.json does not declare |
| Dead files or exports | Code is still present but no longer reachable from project entry points | Makes refactors harder and can hide outdated dependency usage | Reports unused files, exports, types, and duplicate exports |
| CI dependency drift | Dependency issues enter through pull requests without review | Lets dependency hygiene regress over time | Runs as a project-level lint step in CI |
An unused dependency is a package that remains listed in package.json even though the project no longer imports or uses it.
For example, imagine your team switches from Lodash to Radash but forgets to remove Lodash from package.json:
{
"dependencies": {
"lodash": "^4.17.21",
"radash": "^12.0.0"
}
}
If the codebase no longer imports Lodash, it is now an unused dependency. The application may still build and run, but your dependency graph is less accurate than your source code. That mismatch matters because tools, developers, and CI pipelines all use package.json as a source of truth.
Unused dependencies create several kinds of risk:
package.json to understand the stack. If it contains stale libraries, they may assume those packages are still approved or supported.The security issue is not that every unused dependency is immediately exploitable. The issue is that unused dependencies expand the set of packages your team has to trust, patch, audit, and explain.
A ghost dependency is a package your code imports but does not list as a direct dependency in package.json.
Here is the common path:
node_modules tree.That means your source code now relies on Package B, but your package.json does not say so.
// This import works locally because another dependency brought it in.
// But this project did not declare "package-b" in package.json.
import { helper } from 'package-b';
This build may keep passing for months. Then one day, Package A removes Package B, changes its version, or the package manager changes how it lays out dependencies. Suddenly, your app breaks even though nobody touched the import.
Ghost dependencies are also sometimes called phantom dependencies or unlisted dependencies. The names vary, but the problem is the same: your code depends on something your package manifest does not declare.
Ghost dependencies are especially dangerous because they make your dependency graph lie.
The biggest risks are:
This is why dependency hygiene is not just cleanup work. It is part of software supply chain security.
AI agents can help explain a dependency tree, suggest cleanup PRs, or summarize what a package does. They are not enough for dependency hygiene on their own.
Unused and ghost dependency detection requires deterministic analysis across the whole repository. A tool has to inspect entry points, imports, exports, scripts, package manifests, workspace boundaries, and framework conventions. It also has to distinguish real issues from intentional exceptions.
An AI agent can assist with interpretation, but it should not be the system of record. For this problem, you need a project-level dependency linter that can produce repeatable results in local development and CI.
Package managers have improved a lot. pnpm and Yarn Plug’n’Play are stricter than classic node_modules layouts and can reduce accidental reliance on transitive dependencies. Yarn’s Plug’n’Play documentation explicitly calls out ghost dependencies as a risk created by traditional hoisting.
But switching package managers is not always realistic, especially in older monorepos or production systems with custom tooling. Even stricter package managers can have edge cases around workspaces, peer dependencies, root-level dependencies, or intentional hoisting.
The practical answer is not always “switch package managers.” Often, it is “make dependency hygiene visible.” A project-wide linting tool gives you a safer first step: find the problems, review them, and decide how strict your team wants to be.
Knip is a project-level linter for JavaScript and TypeScript projects. It finds unused dependencies, unlisted dependencies, unused files, unused exports, unresolved imports, duplicate exports, and unlisted binaries.
That scope matters. ESLint is excellent for linting individual files and enforcing code-level rules. Knip works at a different level: it analyzes how the repository fits together.
Knip is especially useful because it:
Knip is not a “run one command and delete everything” tool. It is a reporting tool that helps your team make informed decisions. The goal is not automatic deletion. The goal is a dependency graph that accurately reflects how the codebase works.
Knip starts from configured or inferred entry files, follows imports and exports across the project, and compares what it finds against your package manifests.
At a high level, it asks:
package.json are unused?package.json?Because Knip analyzes the project as a graph, it can catch issues that file-level tools miss. That is why it is a good fit for dependency hygiene work.
You can run Knip as a one-time scan:
npx knip
Or install it as a development dependency:
npm install -D knip
Then add a script to package.json:
{
"scripts": {
"knip": "knip"
}
}
Now the team can run:
npm run knip
Knip includes built-in configuration for many frameworks, but you can customize behavior with a knip.json file when needed.
On a large project, the first Knip run can be overwhelming. That is normal. Instead of trying to fix everything at once, filter the report by issue type.
# Find ghost or unlisted dependencies npx knip --include unlisted # Find unused dependencies npx knip --include dependencies # Find unused files npx knip --include files # Find unused exports npx knip --include exports
For targeted cleanup, Knip also supports --fix for some issue types:
npx knip --include dependencies --fix
Use --fix carefully. Removing dependencies, files, or exports should happen on a feature branch, with tests and a manual review. A dependency can look unused to static analysis but still be referenced through dynamic imports, framework conventions, generated code, or runtime configuration.
To show how this works in practice, let’s look at a real open source project: dub.co. The goal is not to criticize the project, but to show how Knip reports dependency hygiene issues in a non-trivial codebase.
A good audit order is:
Start with ghost dependencies because they are usually the highest-risk category. If production code imports a package that is not declared, you want to know early.
npx knip --include unlisted
Here’s a glimpse of how Knip reports ghost dependencies:

Knip reported that server-only was imported from several files but was not listed in package.json. It also flagged a missing package entry file.
That does not automatically mean the project is broken. It means the dependency graph needs review. In a real codebase, the next step is to determine whether the import is:
That distinction is important. Knip gives you the signal. Your team still has to apply project context.
Not every Knip warning means “delete this” or “install this.” Some warnings are intentional.
For example, a framework may use a special import, shim, or type declaration that looks like a package dependency but is not meant to be installed directly. In that case, you can tell Knip to ignore the dependency:
// knip.json
{
"ignoreDependencies": [
"server-only"
]
}
Use ignore rules sparingly. Every exception should have a clear reason. If a future developer sees ignoreDependencies, they should understand whether it exists because of a framework convention, generated code, a virtual module, or a known Knip limitation.
A useful pattern is to add a short comment near the configuration in the PR description or internal docs explaining why the exception exists.
Knip’s production mode does not mean “run Knip in production.” It means “focus the analysis on code and dependencies that matter for production.”
Run:
npx knip --production
This helps reduce development-only noise from tests, Storybook files, local scripts, and build tooling. It is especially useful when your first Knip report is too large to act on.
For stricter production checks, you can also use production mode with strict checks:
npx knip --production --strict
A practical workflow is:
Knip is most valuable when it becomes part of your dependency hygiene workflow, not a one-time cleanup exercise.
A good CI strategy is to run Knip after dependency installation and before expensive test suites. That way, obvious dependency hygiene issues fail fast.
For a GitHub Actions setup, you can add a workflow like this:
# .github/workflows/knip.yml
name: Dependency audit
on:
push:
branches: [main, master]
pull_request:
jobs:
knip:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run Knip audit
run: npx knip --include dependencies,unlisted
You probably do not want every Knip issue to block a pull request on day one. A cleaner rollout is to treat ghost dependencies as stricter than unused dependencies.
For example:
jobs:
knip:
runs-on: ubuntu-latest
steps:
# Previous steps...
- name: Strict check for ghost dependencies
run: npx knip --include unlisted
- name: Warn on unused dependencies
run: npx knip --include dependencies --no-exit-code
By default, Knip exits with a non-zero code when it finds issues. That is useful for strict checks. For advisory checks, --no-exit-code lets CI report the issue without failing the pipeline.
This gives you a gradual adoption path:
Knip works best when it is part of a broader dependency hygiene process.
Use this checklist:
package.json as an architectural document. It should show what the project intentionally depends on.The goal is not a perfectly empty report at all times. The goal is to make dependency drift visible before it becomes a production or security problem.
Unused and ghost dependencies are easy to ignore because they rarely fail loudly at first. An unused package can sit in package.json for years, adding audit noise and maintenance cost. A ghost dependency can work fine locally until a transitive dependency changes and the build suddenly breaks.
Knip gives you a practical way to find both problems. It analyzes the project as a whole, compares real usage against your package manifests, and surfaces the places where your dependency graph no longer matches your code.
The safest workflow is incremental. Start by running Knip locally, then filter the report into smaller categories. Review ghost dependencies first, because production code should not rely on undeclared packages. Add ignore rules only when you understand the exception. Once the baseline is manageable, put Knip in CI so new dependency drift gets caught before it reaches main.
Cleaner dependency graphs reduce attack surface, improve build reliability, and make your codebase easier to understand. The smaller and more accurate your package.json is, the less your team has to trust by accident. If you are working in a Next.js environment, pairing these dependency hygiene practices with Next.js security headers can further harden your application against supply chain and runtime threats.

Learn how Storybook MCP enables AI agents to understand your component library, generate accurate UI, and validate code using documentation, stories, and automated tests.

Debug RSC hydration mismatches in production with Next.js instrumentation, Suspense isolation, HTML diffing, and CI smoke tests.

Explore why npm dependencies are a major supply chain security risk and how to protect JavaScript apps from compromised packages and transitive threats.

Enabled React Compiler v1.0 on a production Next.js app. Here’s every warning, breakage, and silent opt-out I documented — and what actually worked.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now