Why Every Project Should Start with Fewer 2026
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.
The Case Studies: Teams That Went Minimal and Won
The minimal dependency philosophy isn't abstract — it shows up concretely in how successful frameworks and tools were built, and in the flexibility those choices unlocked.
The Remix case: the Remix framework was built with a deliberate "use the web platform" philosophy — minimal dependencies, Web API-first. Request and Response objects are standard Web APIs. Form handling uses the browser's native form submission model. This made Remix runnable on Cloudflare Workers without modification, something most React frameworks couldn't claim. Teams building on Remix inherited this portability — when the edge runtime conversation became important in 2023 and 2024, Remix apps could move to the edge with minimal friction. The dependency philosophy of the framework shaped the deployment optionality of every app built on it.
The Astro approach: Astro's core has minimal dependencies and outputs zero JavaScript to the browser by default — a deliberate design that makes it competitive for content sites. This wasn't an accident. Astro's team made explicit decisions to not pull in heavy runtime dependencies, relying instead on the build toolchain doing more work. Teams building on Astro discovered that their Core Web Vitals scores were strong out of the box because the foundational constraint (no runtime JS unless you ask for it) was baked into the architecture.
The Bun example: Bun's package manager installed Bun itself with 0 npm dependencies in its runtime — the zero-dep constraint was a feature, not a limitation. Shipping with no external dependencies meant no supply chain exposure, no transitive CVEs, no version conflicts. This was a deliberate engineering choice that paid off in security posture and in user trust.
The practical implication: teams that start with fewer dependencies have more flexibility to optimize, migrate to new runtimes, and audit their full codebase. The team that has to ask "what does our codebase actually depend on?" and can answer in minutes has a competitive advantage in maintenance, security response, and infrastructure flexibility. The team that takes 3 days to untangle their dependency graph before they can assess a CVE has let organizational entropy win.
The Dependency Graduation Criteria
Not every dependency is the same. The useful mental model is a set of graduated criteria for deciding when a dependency is actually justified — rather than added out of convenience or anticipation.
The first criterion: the functionality is non-trivial to implement correctly. Auth, crypto, database drivers — these have subtle correctness requirements, security properties, and edge cases that accumulate over years of production usage. Using an established library for these isn't laziness; it's recognizing that the correctness surface area exceeds what a team should try to own. This is the clearest case for a dependency.
The second criterion: the library has a large surface area you'll actually use. Installing a library for one function that represents 1% of its functionality is the efficiency antipattern. You're accepting the full bundle cost, maintenance burden, and security surface of a library for a fraction of its value. If you're using 3 functions from lodash, those functions are candidates for internal utilities.
The third criterion: the maintenance cost of keeping the code internal exceeds the maintenance cost of tracking the dependency. Small utility functions — debounce, format bytes, slugify — are often better as internal utilities than npm dependencies. A 15-line debounce function in your utils folder has no CVEs, no major version upgrades, no API changes. It does exactly what you wrote it to do.
The "intern could write this" test: if a competent junior developer could implement the functionality correctly in 30 minutes, consider whether it's worth the dependency. The test suite requirement applies here: any internal utility written to replace a dependency needs its own tests. If you're unwilling to write tests for it, the dependency is probably the better choice — the original library tested those edge cases so you don't have to.
The final check: for anything you write yourself, ask whether you're also taking on the maintenance burden of edge cases the original library handled over years. RFC compliance, Unicode edge cases, timezone handling, locale-specific formatting — these are the subtle bugs that make "it's simple, I'll write it myself" more expensive than it appeared when you started.
How Dependency Count Correlates with Onboarding Time
The relationship between dependency count and onboarding time is rarely measured but consistently observed. When a new engineer joins a team and opens a codebase with 80 direct dependencies, they face a non-trivial research task before they can contribute confidently. Each dependency represents an API, a set of conventions, a set of failure modes, and a mental model they need to internalize. Some of these are well-known libraries they'll recognize immediately. Others are internal utilities wrapped in npm packages, or niche solutions for specific problems, or legacy choices that nobody has updated since the original author left.
The onboarding overhead compounds because dependencies don't exist in isolation. Understanding how TanStack Query and Zod interact at the API boundary requires understanding both libraries individually before understanding the integration. Understanding why both Axios and fetch are used — and which contexts prefer one over the other — requires reading code, asking questions, or both. Each of these micro-investigations takes minutes individually but hours collectively. A codebase with 80 dependencies has dozens of these interaction patterns to learn.
Lean codebases invert this dynamic. When a codebase has 15 direct dependencies, most of which are well-known (React, Next.js, Tailwind, Zod), a new engineer can form an accurate mental model of the full stack within hours. There are fewer surprises, fewer "why is this here" moments, and fewer undocumented choices to discover through debugging. The team's collective knowledge stays denser because there's less total surface area to cover.
The staffing cost is real. If onboarding each engineer takes 2 weeks instead of 3 because the codebase is lean, and a team of 10 turns over 30% annually, the savings compound across every new hire. Dependency hygiene isn't just a code quality concern — it's an operational one.
Lock File Size as a Proxy for Complexity
The package-lock.json or pnpm-lock.yaml file is one of the most informative signals of a project's actual dependency complexity, and one of the least examined. A lock file for a lean project might be 500 lines. For a bloated project, it can exceed 50,000. That difference isn't just cosmetic — it represents the difference in the transitive dependency graph those projects are managing.
Lock file growth is a lagging indicator of dependency accumulation. When a developer adds a new package, the lock file expands by the number of unique transitive dependencies the new package introduced. Many packages bring dozens. Some bring hundreds. A package that adds 200 lines to your lock file has introduced 200 specific, pinned versions of code your project now depends on, each of which can have its own vulnerabilities, breaking changes, and behavior surprises.
Lock file size also correlates with CI installation time. Projects with 50,000-line lock files typically have install times of 60–120 seconds per CI run, even with caching. Lean projects with 2,000-line lock files can install in 5–10 seconds. At 50 CI runs per day across a team, that's a meaningful daily difference — and installation is just one step in the pipeline.
The lock file is the most honest accounting of what a project actually depends on. It can't be gamed by selective listing in package.json. Monitoring its line count over time is a simple way to detect dependency accumulation before it becomes a problem. If your lock file grows by more than 10% month over month without a major new capability being added to the project, that's a signal worth investigating.
The Stdlib-First Mindset
The JavaScript ecosystem's reliance on npm for functionality that other language ecosystems ship in their standard libraries is a historical accident, not an architectural necessity. Node.js's stdlib has expanded significantly over the past several years, and modern browser APIs cover territory that previously required external packages. Developing a "stdlib-first" mindset means checking platform capabilities before reaching for npm.
The list of common npm packages that can be replaced with platform APIs is longer than most developers expect. Fetch is built into Node.js 18 and all modern browsers — node-fetch and cross-fetch are unnecessary in any project targeting current runtimes. crypto.randomUUID() replaces the uuid package for UUID generation. structuredClone() handles deep object cloning. AbortController manages request cancellation. The URL and URLSearchParams constructors replace most URL manipulation packages. Intl.DateTimeFormat, Intl.NumberFormat, and Intl.RelativeTimeFormat handle locale-aware formatting. Array.prototype.flat(), Object.fromEntries(), and Array.prototype.at() eliminate the most common lodash use cases.
The pattern extends to Node.js-specific utilities. The built-in path, fs/promises, crypto, stream, http, and os modules cover most server-side infrastructure needs without any external dependencies. Many packages in the ecosystem are thin wrappers around these built-ins — sometimes providing a nicer API, sometimes not. Before installing a utility that wraps Node.js built-ins, it's worth evaluating whether the API improvement justifies the dependency.
The stdlib-first mindset extends to a discipline: before opening the npm registry, check MDN and the Node.js documentation. The answer is there more often than developers expect, especially for projects targeting current LTS Node.js versions and modern browser baselines.
The 90-Day Rule in Practice
The 90-day rule for dependencies is simple: any package added to a project that has not appeared in a real import statement within 90 days should be removed. The rule sounds obvious, but it requires active enforcement because package managers make adding packages effortless and removing them requires intentional effort. The asymmetry of effort is the mechanism by which dependency bloat accumulates.
Enforcing the 90-day rule requires a monthly or quarterly audit against git history. The git log --all --oneline --diff-filter=M -- package.json command shows every commit that modified package.json, and from there you can identify packages added within the past 90 days. Checking those packages against the actual import statements in the codebase takes minutes with a grep across the source tree. Packages that appear in package.json but nowhere in the source are candidates for removal.
The rule has legitimate exceptions. Some packages are installed for build-time use — PostCSS plugins, webpack loaders, Babel presets — and may not appear in import statements even though they are actively used. Some packages are peer dependencies that are installed to satisfy another package's requirements. Devtools like depcheck and bundlesize run only in scripts, not in application code. The 90-day rule applies to runtime dependencies where usage should be visible in import statements; build and dev tooling requires a separate evaluation. But the category of legitimate "no import" packages is smaller than most teams assume, and the discipline of verifying each case is worthwhile.
The Decision Gate Before Every Install
Establishing a consistent decision gate — a set of questions that must be answered before npm install is run — is the single most effective intervention for preventing dependency accumulation. The gate needs to be lightweight enough that developers will actually use it, and strict enough that it filters out speculative and redundant installs.
The first question is whether the functionality already exists in the codebase or in the platform. Before installing a date formatting library, check whether Intl.DateTimeFormat covers the required format. Before installing an HTTP client, check whether fetch handles the use case. Before installing a utility function, check whether the function already exists as an internal utility somewhere in the codebase. These checks take two minutes and eliminate a meaningful fraction of would-be installs.
The second question is whether you have the specific problem now — not "might have it soon," but have it today, in code that is being written in this sprint. Installing for anticipated future needs is the "we'll need this" fallacy in action. The cost of adding a library when the need is confirmed is minimal; the cost of maintaining a library that was added speculatively and never used accrues every month it sits in package.json.
The third question is whether the package is the right one for this need. Are there alternatives with better maintenance records, smaller bundle sizes, or narrower scope? A quick comparison on bundlephobia and the package's GitHub page takes five minutes and occasionally surfaces a substantially better option that wasn't the first search result on npm.
The Shrink-to-Fit Refactor as a Growth Signal
When an engineering team decides to systematically remove dependencies — not out of crisis, but out of intentional architectural hygiene — it is frequently a sign of maturity. Projects that started under velocity pressure and accumulated dependencies along the way reach a point where the maintenance burden becomes visible enough to justify investment in reduction. The "shrink-to-fit" refactor is a growth signal.
This refactor has a recognizable pattern. The team runs depcheck and finds a half-dozen genuinely unused dependencies — libraries installed for features that were removed, packages superseded by native capabilities, devDependencies that moved into scripts that were deleted. Removing these is low-risk and immediately improves lock file hygiene and audit surface. The wins compound: npm audit returns fewer false positives, CI caches more efficiently, and the mental model of the project's dependencies becomes more accurate.
The harder part is rationalizing duplicate functionality. A project that has both date-fns and dayjs because two developers made independent choices over 18 months needs someone to pick one, migrate the remaining usages of the other, and remove the loser. This is straightforward when the codebase has good test coverage — run tests after migrating each callsite, and the change is safe. Without test coverage, even a well-understood migration carries some risk.
The discipline this builds is organizational as much as technical. Teams that go through a shrink-to-fit refactor develop an instinct for evaluating new dependencies more carefully before adding them. The cost of the refactor — measured in hours of focused work — serves as a concrete reference point when the next developer proposes adding a new library for convenience. The question "would we have to refactor this out later?" becomes easier to ask when you've recently lived through the alternative.
Auditing Your Existing Dependency Set
Most teams add dependencies carefully but review the existing set rarely. A structured dependency audit, run once per quarter, consistently surfaces removable packages that accumulate silently over the course of active development.
The most effective audit sequence starts with automated detection. Running npx depcheck identifies packages in package.json that have no import statements anywhere in the codebase — these are prime removal candidates. The false positive rate is low enough that each result is worth investigating, and genuine positives (packages that are genuinely unused) can be removed with no risk beyond the removal itself.
The next step examines duplicate functionality. A simple series of grep patterns across the codebase surfaces whether multiple packages are being used for overlapping purposes: multiple date manipulation libraries, multiple HTTP clients, multiple validation schemas. When duplicates exist, pick the one that handles the most use cases in your codebase and migrate the others. This is usually a small amount of search-and-replace followed by a test run.
The third pass covers packages that have been superseded by platform capabilities since they were installed. A project that installed uuid before Node.js 18 landed in LTS should remove it; crypto.randomUUID() is available. A project that installed node-fetch before Node.js 18 should remove it; native fetch is available. A project that installed is-promise or similar tiny utility packages should remove them; these were already bad decisions that haven't gotten better.
The final pass is the 90-day rule applied retroactively: look at packages installed in the last 90 days via git history on package.json, and verify each one has actual usage in the codebase. The "installed speculatively and never used" category is smaller than you'd expect at any given audit, but it exists in every codebase that moves quickly.
Track npm package health and compare alternatives at PkgPulse.
See also: AVA vs Jest and Stop Installing Libraries You Don, Hot Take: Most npm Packages Should Be stdlib.
See the live comparison
View pnpm vs. npm on PkgPulse →