The trigger
After 375 pull requests, my Nx monorepo had grown to six workspaces, 50 MDX content files, and a shared component library shipping to production, and I had no way to tell whether an exported type was still consumed, whether a dependency had a known vulnerability, or how much a new component would cost me in bundle size. Those are three different failure modes, and each one has a dedicated tool.
Knip: dead-code detection
Knip v6 uses oxc for fast AST parsing and ships with plugins for Nx, Next.js, Jest, Storybook, and Playwright. The whole config is a single knip.ts file with per-workspace entry points:
const config: KnipConfig = {
workspaces: {
'.': {
entry: ['scripts/*.ts'],
jest: false, // root jest.config delegates to Nx projects
ignoreDependencies: ['tailwindcss', 'caniuse-lite'],
},
'apps/root': {
entry: [
'src/app/**/page.tsx',
'src/app/**/layout.tsx',
'src/app/**/route.ts',
],
ignoreDependencies: ['gsap', '@gsap/react'],
},
'libs/shared/ui': {
project: ['src/**/*.{ts,tsx}'],
},
},
};The first scan surfaced 6 unused files, 10 unused dependencies, 33 unused exports, and 26 unused exported types. Some of those are false positives, since test-setup.ts is referenced by Jest config rather than by an import, and GSAP loads dynamically. But several were real: dead components left over from a refactor, and a focus-trap-react dependency whose code I'd ripped out but forgot to uninstall.
The configuration decisions that mattered: disable the Jest plugin at root (it tries to read Next.js pages-dir config and errors out), ignore CSS-only dependencies like Tailwind that have no JS import to trace, and set explicit entry points for the App Router (pages, layouts, routes, error boundaries).
For now I run pnpm knip --no-exit-code, and the plan is to phase it into CI gradually: report-only first, then fail on unused dependencies, then full enforcement.
Renovate: dependency updates with cooldowns
Renovate runs as a GitHub App and opens PRs for outdated dependencies. The setting I care about most is minimumReleaseAge: a cooling-off period before Renovate even proposes an update.
{
"minimumReleaseAge": "3 days",
"packageRules": [
{
"matchDepTypes": ["dependencies"],
"matchUpdateTypes": ["patch"],
"minimumReleaseAge": "7 days",
"automerge": true,
"platformAutomerge": true
},
{
"matchUpdateTypes": ["major"],
"minimumReleaseAge": "14 days",
"automerge": false
}
]
}Three days for devDependencies, seven for production, fourteen for majors. Most malicious npm packages get reported and yanked within 72 hours, so a three-day cooldown would have caught the majority of 2025's supply chain attacks. It isn't a security guarantee; it's a probability filter.
The other setting that matters is package grouping. Nx has eight packages that have to stay in lockstep, Storybook has eight, and TypeScript and ESLint are pinned together via overrides. Without grouping, Renovate opens eight separate PRs for one Nx version bump, each of which is broken until all eight merge. Grouping collapses them into a single PR:
{
"matchPackagePatterns": ["^@nx/", "^nx$"],
"groupName": "Nx",
"automerge": false
}GitHub Actions get pinned to digest hashes rather than version tags, because a compromised tag can be force-pushed and a digest can't.
Size-limit: bundle-cost tracking
Size-limit measures the minified-and-compressed cost of specific imports. I track five entry points in the shared-ui library:
[
{
"name": "shared-ui (all exports)",
"path": "libs/shared/ui/src/index.ts",
"import": "*",
"limit": "25 kB"
},
{
"name": "Button",
"path": "libs/shared/ui/src/lib/Button.tsx",
"import": "{ Button }",
"limit": "10 kB"
},
{
"name": "Modal",
"path": "libs/shared/ui/src/lib/Modal.tsx",
"import": "{ Modal }",
"limit": "15 kB"
},
{
"name": "Toast",
"path": "libs/shared/ui/src/lib/Toast.tsx",
"import": "{ ToastProvider, useToast }",
"limit": "15 kB"
},
{
"name": "Dropdown",
"path": "libs/shared/ui/src/lib/Dropdown.tsx",
"import": "{ Dropdown }",
"limit": "13 kB"
}
]Budgets sit about 15% above the current sizes, and the @size-limit/preset-small-lib preset uses esbuild for fast bundling. On PRs a GitHub Action compares sizes against the base branch and posts a table comment with the deltas; locally, pnpm size checks budgets and pnpm size:why opens a treemap.
The per-entry-point approach is the part that matters, because tracking only the barrel export hides component-level bloat. If Modal picks up a heavy dependency, the "all exports" number might creep up 3% and stay within budget while the Modal entry jumps 40% and blows past it. Per-component budgets catch the regressions an aggregate budget quietly masks.
The pattern
Each tool covers a specific failure mode that compounds over time:
- Knip: dead code accumulates silently after refactors
- Renovate: dependencies go stale, then vulnerable, then breaking
- Size-limit: bundle size creeps one import at a time
None of them took more than an hour to configure, and all three run without any ongoing attention from me. The cost of adding them is one config file each; the cost of skipping them is the eventual cleanup sprint that eats days.
The tools that run on their own find the problems a code review quietly walks past.
