Why Every Project Should Start with Fewer Dependencies
·PkgPulse Team
TL;DR
Start every project with zero dependencies beyond your core framework. Add libraries only when you have a specific problem that can't be solved without them. The projects that stay lean at the start remain lean over time. The projects that install 50 packages on day one end up with 200 by month six, half of which nobody remembers adding. Dependency accumulation is a ratchet — it's easy to add, painful to remove. Intentional minimalism at the start is the cheapest time to establish this discipline.
Key Takeaways
- Dependency accumulation is a ratchet — one direction, hard to reverse
- The "we might need this" install is almost always wrong
- The 90-day rule: if a dependency hasn't been justified in code within 90 days, remove it
- Start with the framework defaults — add only when the framework fails you
- The baseline: Next.js, TypeScript, Tailwind, Zod is enough for most apps at launch
How Dependency Bloat Actually Happens
Week 1 (project kickoff):
Team decides: "Let's set up the standard stack"
npm install react next typescript tailwindcss zod
→ 5 reasonable choices. Clean.
Week 2 (first features):
Developer A adds: "@tanstack/react-query" — good, needed for server state
Developer B adds: "react-hook-form" — good, forms are complex
Developer C adds: "framer-motion" — "we'll need animations"
→ B and C are debatable, but reasonable
Week 4 (feature velocity increases):
"We need charts" → npm install recharts
"We need dates" → npm install date-fns (good) + moment (mistake, already have date-fns)
"We need email templates" → npm install @react-email/components react-email
"I prefer axios" → npm install axios (fetch exists, but OK)
→ First signs of redundancy (moment + date-fns)
Month 3 (team of 6, moving fast):
"This page needs a data table" → npm install @tanstack/react-table
"We need rich text" → npm install slate-react slate (250KB added)
"Payment analytics" → npm install recharts d3 (recharts already installed!)
"I want to use lodash" → npm install lodash (modern JS makes this unnecessary)
→ Duplicates: recharts + d3, date-fns + moment, lodash + built-ins
Month 6:
npm ls --depth=0 | wc -l
→ 87 direct dependencies
Running npm audit:
→ 23 vulnerabilities
Running bundle analyzer:
→ 2.3MB initial JavaScript bundle
Nobody remembers why slate is there.
Nobody uses the "recharts" original install — d3 replaced it.
moment is loaded but nothing uses it (date-fns does the work).
lodash is loaded but only used for _.get (optional chaining exists).
This is how it happens. Every step was reasonable.
The total was not.
The Minimum Viable Dependency Set
# For a modern Next.js/React app, this is genuinely enough to launch:
# Core:
next react react-dom typescript
tailwindcss @tailwindcss/forms
zod
next-auth OR clerk # Authentication
# That's it for v1 of most apps.
# What this handles:
# → TypeScript: type safety across the codebase
# → Tailwind: styling (no CSS-in-JS needed)
# → Zod: API validation, form validation, env variables
# → Next.js: routing, SSR, API routes, image optimization
# → Auth: user accounts and sessions
# What you probably don't need yet:
# → State management library (TanStack Query for server state; built-ins for client)
# → Form library (uncontrolled forms + Zod cover most cases)
# → Animation library (CSS transitions cover 80% of animations)
# → Date library (native Date + Intl.DateTimeFormat cover basics; add when you need more)
# → Component library (Tailwind + shadcn/ui via copy-paste is better than a full library)
# → HTTP client (fetch is built-in)
# → Utility library (modern JavaScript covers it)
# When to add:
# → TanStack Query: when you have >3 server data sources with loading states
# → date-fns: when you have >5 distinct date operations
# → framer-motion: when you need physics-based animations specifically
# → react-hook-form: when you have >5 fields with complex validation
# → Radix UI: when you need accessible dropdowns, dialogs, tooltips
# The principle: add when you have the problem, not before.
The "We'll Need It" Fallacy
Developer reasoning:
"We'll definitely need charts at some point, so I'll set up recharts now."
→ Install recharts
→ It stays in package.json
→ 6 months later: we used highcharts because recharts didn't support what we needed
→ Both packages installed, only one used
"Let me set up i18n now since we plan to go international."
→ Install react-i18next + i18next
→ Configure all the boilerplate
→ International expansion never happens (most startups pivot)
→ i18n library added complexity with zero value delivered
"We'll be doing a lot of data processing, let me add lodash."
→ Install lodash
→ Used for: _.groupBy() once, in one script
→ 71KB of JavaScript loaded on every page for one rarely-called utility
The reality:
→ You don't know what you'll need
→ Adding infrastructure for hypothetical future needs is premature optimization
→ The cost of adding a library when you need it: 30 minutes
→ The cost of maintaining a library you don't need: years
The better approach:
→ Feel the pain of the missing abstraction
→ Write the 20 lines yourself first
→ Add the library when you've written the same logic 3 times and hate it
→ This is Sandi Metz's "Rule of Three" applied to dependencies
How to Maintain Dependency Hygiene Over Time
# Monthly dependency audit (takes 15 minutes):
# Step 1: Find unused dependencies
npx depcheck
# Output:
# Unused dependencies:
# * moment ← Nobody uses it, date-fns replaced it
# * recharts ← Switched to highcharts 3 months ago
# Unused devDependencies:
# * @types/express ← We moved away from Express
# Remove unused deps immediately:
npm uninstall moment recharts @types/express
# Step 2: Check for duplicate functionality
# Do you have two packages that do the same thing?
grep -rh "from 'date-fns\|from 'moment\|from 'dayjs\|from 'luxon'" src/ | sort -u
# If you have multiple date libraries: pick one, migrate, remove the rest
# Step 3: Check for native alternatives to old packages
node --version # Are you on Node 18+?
grep -r "node-fetch\|cross-fetch" src/ # Replace with native fetch
grep -r "uuid" src/ # Replace with crypto.randomUUID()
grep -r "is-url\|is-email\|is-integer" src/ # Replace with 1-line functions
# Step 4: Review packages added >90 days ago that aren't used heavily
# Look at git history for recent package.json additions
git log --all --oneline --diff-filter=M -- package.json | head -10
# Check if those additions actually made it to code
The Projects That Get This Right
What lean dependency discipline looks like in practice:
The Rails convention (relevant for JavaScript):
→ "Convention over configuration"
→ Rails ships with everything a web app needs
→ Resisted adding every trending gem to the core
→ Result: Rails apps from 2015 still run with minimal changes
The Svelte approach:
→ Svelte's compiler does more, requires fewer runtime packages
→ Svelte Kit comes with routing, build tools, all included
→ Philosophy: include what's needed, nothing more
The Go approach:
→ Rich stdlib means fewer third-party packages
→ Community norm: stdlib first, external package only for real gains
→ Result: Go binaries are self-contained, dependencies are few
JavaScript can't follow Go's path (language is too different).
But the mindset applies:
→ Before npm install: "Can I do this without a new package?"
→ Before keeping a package: "Is this still being used?"
→ Before starting a project: "What's the minimum to ship?"
The projects that are easy to maintain in year 3 are the ones
where someone cared about dependency hygiene in year 1.
It's not about minimalism for its own sake.
It's about intentionality — knowing what's in your stack and why.
Track npm package health and compare alternatives at PkgPulse.
See the live comparison
View pnpm vs. npm on PkgPulse →