Every project collects junk over time. A component nobody renders anymore. A utils.ts export from a feature you deleted six months ago. A package in package.json you installed "just to try" and never removed.
None of it breaks the build, so nobody notices. But it costs you anyway. Bigger installs, slower CI, and every new teammate has to wonder whether formatLegacyDate is still important.
Your linter can't find most of this. Biome or ESLint look at one file at a time, so they can tell you a variable is unused inside a file. They can't tell you that an export isn't imported anywhere else, or that a file isn't imported at all. For that you need a tool that looks at the whole project. That tool is Knip.
In this post I'll explain what Knip finds, how it works, how to set it up, and how I run it on this site. This is the third post in my small tooling series, after Biome vs ESLint + Prettier and Husky vs Lefthook.
The short version#
- Knip finds unused files, exports, types, and dependencies in JavaScript and TypeScript projects. It also finds dependencies you use but forgot to list in
package.json. - It works by starting from your entry files and following every import. Anything it can't reach is unused.
- It has plugins for 100+ tools (Next.js, Vite, Vitest, Storybook, and more), so most projects need very little config.
knip --fixcan remove unused exports and dependencies for you. Review the diff before you commit.- Run it in a Git hook and in CI, so dead code gets caught while it's still small.
What a linter can't see#
Here's the difference in one example:
// src/lib/format.ts
const DEFAULT_LOCALE = "en"; // never used in this file
export function formatDate(date: Date) {
return date.toLocaleDateString(DEFAULT_LOCALE);
}
export function formatLegacyDate(date: Date) {
// nobody imports this anymore
return date.toISOString();
}A linter only reads this one file. It sees that formatLegacyDate is exported, and an export could be used anywhere, so it says nothing.
Knip reads every file and builds a graph of who imports what. It sees that no file imports formatLegacyDate and reports it.
What Knip finds#
These are the main issue types:
| Issue type | What it means | Auto-fix |
|---|---|---|
| Unused files | No file imports it, and it's not an entry file | Yes (with --allow-remove-files) |
| Unused dependencies | In package.json, but never imported or used |
Yes |
| Unused devDependencies | Same, for devDependencies |
Yes |
| Unlisted dependencies | Imported in code, but missing from package.json |
No |
| Unlisted binaries | A script uses a CLI that no dependency provides | No |
| Unresolved imports | An import path that points to nothing | No |
| Unused exports | Exported, but no other file imports it | Yes (removes export) |
| Unused exported types | Same, for types and interfaces | Yes |
| Unused enum members | An enum value nobody uses | Yes |
| Duplicate exports | The same thing exported twice under different names | No |
The unlisted dependencies check is easy to overlook, but it's one of the most useful. It catches the bug where your code works on your machine only because some other package happened to install that dependency for you.
How it works#
Knip's idea is simple:
- Find the entry files. These are the places your code starts:
src/index.ts, Next.js pages and layouts, config files, test files, scripts inpackage.json. - From each entry file, follow every
import,require, and re-export, and build a module graph. - Compare the graph with everything else:
- A project file that's not in the graph is an unused file.
- An export that no file imports is an unused export.
- A dependency in
package.jsonthat no file or script uses is an unused dependency.
The hard part is step 1. Every framework has its own magic files. Next.js loads app/**/page.tsx without anyone importing it. Vitest loads *.test.ts. Tailwind reads tailwind.config.ts.
That's what plugins are for. Knip has a plugin for each popular tool, and a plugin turns on automatically when the tool is in your package.json. The Next.js plugin, for example, adds app/**/{layout,page,route,template}.tsx, middleware.ts, proxy.ts, sitemap.ts, opengraph-image.tsx, and the other special files as entry files. You don't write any of that yourself.
Plugins also read config files. If your vitest.config.ts uses @vitejs/plugin-react, Knip knows that dependency is used, even though no source file imports it.
Setup#
The fastest way is the init command:
pnpm create @knip/configIt installs knip, adds a knip script to package.json, and you're done. Or do it by hand:
pnpm add -D knip typescript @types/node// package.json
{
"scripts": {
"knip": "knip"
}
}Then run it:
pnpm knipStart without a config file. Knip's defaults plus its plugins work for a lot of projects. Only add config when the output tells you something is wrong.
Reading the output#
When I add an unused file and an unused export to this site and run Knip, I get this:
Unused files (1)
src/lib/old-utils.ts
Unused exports (1)
slugifyTitle function src/lib/brand.ts:11:17Each section is one issue type, with the file path and, for exports, the exact line and column. If everything is clean, Knip prints nothing and exits with code 0. If it finds issues, it exits with code 1, which is what makes it useful in hooks and CI.
On a big, older project, the first run can show hundreds of issues. Don't panic and don't fix them all in one pull request. Start with one category:
pnpm knip --dependencies # only dependency issues
pnpm knip --files # only unused files
pnpm knip --exports # only unused exports and typesDependencies are usually the quickest win. Then files, then exports.
Configuration#
Knip reads knip.json, knip.jsonc, knip.ts, or a knip key in package.json. This is the full config for this site:
// knip.json
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"project": ["**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx,css}"],
"ignoreExportsUsedInFile": true
}That's it. The Next.js plugin handles all the entry files. Two options are set:
projectsays which files Knip should check for "unused file". I addedcssso an orphaned stylesheet gets reported too.ignoreExportsUsedInFilestops Knip from reporting an export that's still used inside its own file. I like to export a helper type next to the function that uses it, even if no other file imports it yet. This keeps those quiet.
These are the options you'll reach for most often:
| Option | Use it for |
|---|---|
entry |
Extra entry files Knip can't find on its own, like a custom script |
project |
Which files to check for "unused file" |
ignoreDependencies |
Packages that are used in a way Knip can't see |
ignoreBinaries |
CLIs used in scripts that aren't from a dependency |
ignoreFiles |
Files to skip in the unused-file check only |
workspaces |
Per-package config in a monorepo |
plugins |
Turn a plugin on or off, or point it to a custom config path |
Handling false positives#
Sometimes Knip reports something that is used. Don't reach for ignore first. That hides every issue type for those files, including real ones. Fix the cause instead:
1. Knip doesn't know about an entry file. Add it to entry:
{
"entry": ["scripts/*.mjs"]
}2. A dependency is used in a way Knip can't see, like a package loaded by a string name at runtime, or a CLI called from a shell script Knip doesn't read:
{
"ignoreDependencies": ["some-runtime-plugin"]
}3. An export is a public API on purpose, like a library that other projects import. Tag it with JSDoc:
/** @public */
export function createClient() {
// used by other packages, not inside this repo
}4. You don't understand why something is reported. Ask Knip:
pnpm knip --trace-export formatDate # where is this export used?
pnpm knip --trace-dependency shiki # where is this package used?
pnpm knip --debug # which entry and project files did it find?Knip also prints configuration hints, like "this ignoreDependencies entry isn't needed anymore". Read them. They keep your config from growing ignore lists you don't need.
Auto-fix#
Once the report looks right, Knip can clean it up for you:
pnpm knip --fixWhat it actually does:
- Unused exports: removes the
exportkeyword. The code stays, so your linter can then tell you if it's unused inside the file too. - Unused dependencies: removes them from
package.json. Runpnpm installafterwards to update the lockfile. - Unused files: only deleted if you add
--allow-remove-files.
pnpm knip --fix --allow-remove-files --format--format runs your formatter (Biome, Prettier, or dprint) on the changed files, so you don't get a messy diff.
Two things to keep in mind:
- Always review the diff. Knip is good, but it can't see a file loaded with a dynamic string path. Git is your undo button.
- Run Knip again after fixing. Removing one unused export can make the function it called unused too. A second run finds the next layer.
On this site I have both commands as scripts:
// package.json
{
"scripts": {
"check:unused": "knip --max-issues 1",
"check:unused:fix": "knip --fix"
}
}--max-issues sets how many issues are allowed before Knip fails. The default is 0, which is the strictest. I keep a buffer of one so a single half-finished export doesn't block a work-in-progress commit. CI still catches it before it reaches main.
Production mode#
By default, Knip checks everything: source code, tests, Storybook stories, config files. That has one blind spot. If a function is only imported by its own test, Knip sees the import and says it's used. But your users never run that code.
Production mode fixes that:
pnpm knip --productionIt only looks at production entry files (plugins already know which ones those are, like Next.js pages), ignores tests and stories, and only checks dependencies, not devDependencies. If you configure entry or project yourself, mark the production patterns with a ! at the end:
{
"entry": ["src/index.ts!", "scripts/*.ts"],
"project": ["src/**/*.ts!"]
}Production mode doesn't replace the normal run. Use both: the normal run keeps your whole repo tidy, and the production run finds code that only exists for its own tests.
Running it in hooks and CI#
Knip is most useful when it runs automatically. On this site it runs in the pre-commit hook with Lefthook, in parallel with Biome and the type check:
# lefthook.yml
pre-commit:
parallel: true
jobs:
- name: biome
glob: "*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,css}"
run: pnpm exec biome check --write --no-errors-on-unmatched --files-ignore-unknown=true {staged_files}
stage_fixed: true
- name: typecheck
run: pnpm run typecheck
- name: unused-code
run: pnpm run check:unusedNote that Knip always checks the whole project, not just staged files. That's the point: deleting an import in one file can make a different file unused. On a small or medium project this takes a second or two. On a big monorepo, add --cache, or move Knip to pre-push or CI.
In CI, a basic step is enough:
# .github/workflows/ci.yml
- name: Check unused code
run: pnpm knipOn GitHub Actions you can also use --reporter github-actions, so issues show up as annotations in the pull request's diff.
Knip vs the older tools#
Before Knip, you needed three tools for this job:
| Tool | What it did | Knip equivalent |
|---|---|---|
| ts-prune | Unused exports | knip --exports |
| depcheck | Unused and missing dependencies | knip --dependencies |
| unimported | Unused files and dependencies | knip --production --dependencies --include files |
ts-prune and unimported are now archived, and their READMEs recommend Knip. One tool that understands your framework beats three tools that each guess.
My tips#
- Start with no config. Add options only when the output tells you something is wrong.
- Fix by category. Dependencies first, then files, then exports. Small pull requests are easier to review.
- Fix the cause, not the symptom. Prefer
entryandignoreDependenciesoverignore. - Read the configuration hints. They tell you when an ignore isn't needed anymore.
- Review every
--fix. Then run Knip again to find the next layer. - Automate it. A Git hook for fast feedback, CI for the final check. Dead code is cheap to remove when it's one export, and painful when it's a whole forgotten feature.
For me, Knip is one of those tools I add on day one now, together with Biome and Lefthook. It's quiet most of the time, and it shows up exactly when I delete a component and forget the helper that only that component used. Your package.json and your future teammates will thank you.
Happy cleaning! ๐งน
References#
- Knip documentation, official site
- Getting started and Configuration, Knip docs
- Issue types, the full list of what Knip reports
- Auto-fix and Production mode, Knip docs
- Next.js plugin, which entry files Knip adds for Next.js
- CLI reference, every flag, including the
--trace-*options - Comparison & migration, moving from ts-prune, depcheck, and unimported
- Knip on GitHub
- Remove unused files and dependencies with Knip, OpenReplay Blog
- Knip vs ts-prune vs depcheck 2026, PkgPulse
