There's a special kind of pain that comes from ignoring dependencies for six months and then trying to update everything at once. You run npm update, half the app breaks, and you spend a Friday night reading twelve GitHub issues about peer dependency conflicts between packages that were last updated when React 17 was current. We've done this. It's not fun. The fix isn't complicated, but it does require actually having a system instead of just hoping things work out.
Why Dependencies Go Stale in the First Place
Most teams don't ignore updates on purpose. It happens gradually. You ship a feature, CI is green, you move on. Three months later someone opens a security audit and there are 47 vulnerabilities, 12 of which are critical. The package.json has drifted so far from current that updating anything requires updating everything, which requires testing everything, which nobody has time to do right now, so it goes back on the backlog. Repeat until the project is a museum exhibit.
The other failure mode is the opposite: someone enables Dependabot, gets 30 PRs a day, merges them all without looking, and discovers three weeks later that a 'patch' update to a date library changed how UTC offsets work in their timezone-sensitive billing code. Both extremes are bad. You want a process that's sustainable — not heroic maintenance sprints, not blindly merging robot PRs.
Start With a Realistic Audit
Before you can manage dependencies, you need to actually know what you have. Run this and take five minutes to look at the output:
# See what's outdated
npm outdated
# Or with yarn
yarn outdated
# Get a cleaner view with npx
npx npm-check-updates
# Check for security issues separately
npm audit
npm audit --audit-level=high # only show high/criticalnpm outdated gives you three columns that matter: Current (what you have), Wanted (what semver allows), and Latest (what actually exists). If Current and Wanted are the same but Latest is way ahead, you're on an old major version. If Wanted is ahead of Current, you have update room within your existing semver range — those are usually safe to apply.
Separate your audit into three buckets: security vulnerabilities (fix now, non-negotiable), major version updates (needs research and testing), and everything else (can batch and schedule). Treating all updates the same is what leads to either paralysis or recklessness.
The Actual Update Workflow That Works
Here's what we do, and it took us embarrassingly long to settle on something this boring:
- Patch updates (1.2.3 → 1.2.4): apply immediately, they're bug fixes and you want them
- Minor updates (1.2.3 → 1.3.0): apply weekly in a batch, run tests, done
- Major updates (1.2.3 → 2.0.0): research each one, read the migration guide, do it deliberately
- Security fixes: drop everything, fix now regardless of version bump size
For patch and minor updates you can automate most of this. Here's a quick script to update within safe semver ranges and run your tests:
#!/bin/bash
# safe-update.sh — update patch/minor, run tests, report results
echo "Checking for safe updates..."
# Update only patch and minor versions (respects ^ and ~ in package.json)
npm update
# Run your test suite
if npm test; then
echo "All tests pass. Committing updates."
git add package.json package-lock.json
git commit -m "chore: update dependencies (patch/minor)"
else
echo "Tests failed after update. Rolling back."
git checkout package.json package-lock.json
npm install
exit 1
fiThis is the kind of script you run on a Monday morning. Not glamorous, but it means you're never six months behind on patch updates. The key line is the git checkout rollback — if something breaks, you want to be back to a known-good state in 30 seconds, not doing archaeology.
Handling Major Version Updates Without Pain
Major updates are where people get hurt. A major version bump means the library author decided backward compatibility was optional. Sometimes this is nothing — they bumped major because they dropped support for Node 12 and you're on Node 20 anyway. Sometimes it's a complete API rewrite. You have to actually look.
The research checklist for any major update:
- Read the CHANGELOG or release notes — not the whole thing, just the major version entry
- Search GitHub issues for 'migration' or 'breaking change' from the last 30 days
- Check if your usage patterns are in the breaking changes list (grep your codebase for the API that changed)
- Look for a codemod — many major frameworks ship automated migration tools
- If it's a transitive dependency (something you don't use directly), check if your direct dep has already updated to support the new version
For Next.js specifically, major version upgrades are well-documented but genuinely involved. The jump from Next.js 13 to 14, for example, deprecated a bunch of Pages Router patterns and changed how caching works in the App Router. We'd rather spend two hours on a deliberate upgrade than spend two hours debugging mysterious cache behavior at midnight.
// Quick script to see which of your direct dependencies have major updates available
// Run with: npx ts-node check-majors.ts
import { execSync } from 'child_process';
import fs from 'fs';
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
const allDeps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
const outdated = JSON.parse(
execSync('npm outdated --json', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }) || '{}'
);
const majorUpdates = Object.entries(outdated)
.filter(([pkg, info]: [string, any]) => {
const currentMajor = parseInt(info.current.split('.')[0]);
const latestMajor = parseInt(info.latest.split('.')[0]);
return latestMajor > currentMajor;
})
.map(([pkg, info]: [string, any]) => ({
package: pkg,
current: info.current,
latest: info.latest,
}));
if (majorUpdates.length === 0) {
console.log('No major updates available.');
} else {
console.log('Major updates available:');
majorUpdates.forEach(({ package: pkg, current, latest }) => {
console.log(` ${pkg}: ${current} → ${latest}`);
});
}Automating the Boring Parts Without Losing Control
Dependabot or Renovate are genuinely useful if you configure them properly. The default configuration that creates a PR for every single package update is overwhelming and people stop reviewing them. The configuration that actually works:
// renovate.json — a sane Renovate configuration
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"],
"timezone": "Europe/Bucharest",
"schedule": ["every weekend"],
"packageRules": [
{
"matchUpdateTypes": ["patch", "minor"],
"matchDepTypes": ["devDependencies"],
"groupName": "dev dependencies",
"automerge": true
},
{
"matchUpdateTypes": ["patch"],
"matchDepTypes": ["dependencies"],
"groupName": "production patches",
"automerge": true
},
{
"matchUpdateTypes": ["minor", "major"],
"matchDepTypes": ["dependencies"],
"groupName": "production updates",
"automerge": false,
"assignees": ["your-github-username"]
},
{
"matchPackageNames": ["next", "react", "react-dom"],
"groupName": "core framework",
"automerge": false,
"labels": ["needs-review"]
}
]
}The key decisions here: dev dependency patches and minors can automerge because the worst case is a slightly different test runner output. Production patches can automerge if your test suite is solid. Minor and major production updates need a human to look at them — that's the group with real risk. Framework packages (Next.js, React) always need a human; automerging a Next.js major is how you get a surprise 3-hour debugging session.
Your automerge confidence should be proportional to your test coverage. If you have no tests, nothing should automerge. If you have solid integration tests that hit real database queries and API routes, patch automerge is reasonable. Be honest with yourself about which category you're in.
Lock Files Are Not Optional
If your package-lock.json or yarn.lock isn't committed to version control, fix that today. This is not a discussion. Lock files mean that npm install on your laptop, in CI, and on the server all install exactly the same thing. Without them, you get the classic 'works on my machine' dependency version drift where some developer's local node_modules has package@2.1.0 and production has package@2.1.4 and there's a subtle behavior difference between them that you will spend three hours blaming on your own code.
One thing people miss: npm install and npm ci are different. npm ci is what you should use in CI/CD and production deployments. It deletes node_modules and installs exactly what's in the lock file. npm install will update the lock file if package.json has changed. Both have their place but mixing them up gives you non-deterministic builds.
# In your GitHub Actions workflow
- name: Install dependencies
run: npm ci # NOT npm install
# npm ci:
# - Requires package-lock.json to exist
# - Deletes node_modules before installing
# - Never updates package-lock.json
# - Faster than npm install in CI environments
# - Fails if package-lock.json is out of sync with package.jsonWhen Things Break Anyway
Even with good process, updates break things sometimes. The response matters. First, figure out which update caused the issue — git bisect is your friend if you batched multiple updates. Once you've identified the culprit, pin to the last known good version immediately:
# Pin a specific version to stop the bleeding
npm install some-package@1.4.2
# Or use package.json overrides to force a specific version
# even in transitive dependencies (npm v8.3+)
# package.json:
# "overrides": {
# "problematic-transitive-dep": "2.1.0"
# }
# Then document why you pinned it with a comment in package.json
# or a note in your changelog — future you will thank present youThe part people forget: open a GitHub issue on the breaking package (or find the existing one) and subscribe to it. Pin the version, write a TODO comment with the issue link, and move on. Don't just pin and forget — set a reminder to check back in a month. Pinned versions accumulate into the same 'too scared to update anything' problem you were trying to avoid.
We've had this happen with date-fns, with some Prisma client updates, and once memorably with an innocuous-sounding eslint plugin that changed how it counted certain code patterns and suddenly our CI was failing on thousands of 'errors' that weren't actually errors. Pinning took 30 seconds. Understanding what happened took an hour. The hour was worth it — we understand our toolchain better now.
If you're starting a new project and want a solid dependency baseline without having to figure all this out from scratch, our templates at peal.dev ship with pinned, tested, known-good dependency combinations — so you start with a foundation that actually works together rather than discovering incompatibilities on day one.
The Practical Takeaway
Stop treating dependency management as something you do when things break. Here's the actual system in one place:
- Commit your lock file. Always. No exceptions.
- Use npm ci in CI/CD, npm install locally.
- Apply patch updates immediately, minor updates weekly, major updates deliberately.
- Use Renovate or Dependabot with grouped, scheduled updates — not the default firehose.
- Automerge only what your test suite can actually catch.
- When something breaks, pin first, investigate second, document why you pinned.
- Run npm audit on a schedule and treat high/critical vulnerabilities as bugs, not backlog items.
None of this is exciting. It's exactly the kind of boring operational hygiene that keeps you from having exciting problems. The goal is a codebase where 'let's update our dependencies' is a normal Tuesday task, not a weekend project that requires a war room. You'll know you've got it right when you update six packages, tests pass, and you deploy without a second thought. That feeling is worth setting up the system.
