Nakodo is a Next 16 app that runs outreach campaigns for brands: it finds creators and businesses that fit, writes to them, and hands over the ones that say yes. The product is described on how it works. None of that is this post.
This post is package.json, because it is the shortest honest answer to "what does this app not do itself", and because I had never counted it.
The numbers
$ node -e "const p=require('./package.json');console.log(Object.keys(p.dependencies).length, Object.keys(p.devDependencies).length)"
30 13
$ wc -l pnpm-lock.yaml
11363 pnpm-lock.yaml
Thirty runtime dependencies, thirteen development ones, and a lockfile holding 1,093 package entries. So 43 names I chose turn into 1,093 I did not, which is the usual ratio and still worth writing down once.
The 30 runtime dependencies, sorted by how many files in src import them:
$ for d in next react drizzle-orm lucide-react zod cn radix-ui stripe ...; do
echo "$(grep -rl "from \"$d\"\|from \"$d/" src | wc -l) $d"
done | sort -rn
112 next
106 react
67 drizzle-orm
52 lucide-react
26 zod
24 cn
16 radix-ui
10 stripe
5 node-html-parser
4 posthog-js
4 class-variance-authority
3 sonner
3 @upstash/redis
2 @trigger.dev/sdk
2 resend
2 recharts
2 @upstash/ratelimit
2 @supabase/ssr
1 tldts
1 robots-parser
1 postgres
1 postal-mime
1 openai
1 disposable-email-domains
1 @supabase/supabase-js
1 @duckdb/node-api
0 react-dom
0 shadcn
0 tw-animate-css
0 next-themes
Two things in that list are worth more than the count: the nine libraries imported exactly once, and the four imported never.
Nine libraries, nine wrapper modules
tldts src/lib/contacts/business.ts
robots-parser src/lib/http.ts
postgres src/db/index.ts
postal-mime src/app/api/email/inbound/route.ts
openai src/lib/ai.ts
disposable-email-domains src/lib/email/disposable.ts
@supabase/supabase-js src/app/auth/confirm/route.ts
@duckdb/node-api src/lib/places/overture.ts
That is eight files for eight libraries, plus @trigger.dev/sdk, which is imported twice because a task file and the thing that triggers it both need it.
None of this was a policy. I did not write down "wrap every dependency". It happened because each of these libraries answers one question, and the question deserved a name: src/lib/http.ts is the only place that fetches a URL somebody else typed, so robots-parser lives there and nowhere else. src/lib/ai.ts is the only place a model is called, so openai is imported once. The parser for inbound mail is imported inside the route that receives inbound mail.
The property this gives you is boring and valuable: the blast radius of a breaking major version is one file, and you can see it without a build. When one of these has a breaking major, there is exactly one file to read. The counter-example in the same list is drizzle-orm at 67 files and lucide-react at 52, and I would not pretend either is swappable.
lucide-react at 52 files is the one I keep looking at. Every icon on the pricing page is one, and 52 files importing an icon set means the icon set is part of the app's vocabulary rather than a dependency. I measured which of its icons we actually use in an earlier post; the dependency count is the other half of the same fact.
The four that never appear in an import
This is where a grep stops being an audit. Four packages are in dependencies and appear in zero from "..." statements, for four different reasons.
react-dom is the renderer. React and Next import it; the app never does directly. It is a dependency because npm has no way to say "the thing that renders, chosen by me, used by them". Pinned to the same exact version as react, which is the only sane way to hold those two.
shadcn and tw-animate-css are imported, just not by TypeScript:
/* src/app/globals.css */
@import "tw-animate-css";
@import "shadcn/tailwind.css";
Those are CSS imports, resolved out of node_modules by the Tailwind pipeline, and no amount of grepping for from " will find them. shadcn being a runtime dependency rather than a dev one looks wrong until you notice that the app imports a stylesheet out of the CLI's own package, so the package has to be installed wherever the CSS is built. The lesson that cost me ten minutes: a dependency audit has to grep the CSS too, and @import is a dependency edge.
next-themes has no excuse. Zero TypeScript imports, zero CSS imports, nothing. It was added when the dark palette was going to be user-switchable, the switch never shipped, and the package stayed. It is dead weight in the lockfile and it should come out.
$ grep -rn "next-themes" --include="*.ts" --include="*.tsx" --include="*.css" . | grep -v node_modules
$ echo $?
1
One dependency in 30 that is pure residue is not a bad hit rate. It is also exactly the kind of thing nobody finds without counting, because an unused dependency breaks nothing, ever.
Nine exact pins and 21 carets
$ node -e "const p=require('./package.json');const all={...p.dependencies,...p.devDependencies};
console.log(Object.entries(all).filter(([,v])=>/^\d/.test(v)).map(([k,v])=>k+'@'+v).join('\n'))"
@duckdb/node-api@1.5.6-r.1
@trigger.dev/sdk@4.7.2
next@16.3.7
react@19.2.8
react-dom@19.2.8
recharts@3.8.0
@trigger.dev/build@4.7.2
eslint-config-next@16.3.7
trigger.dev@4.7.2
Three groups. The framework trio moves together or not at all, and eslint-config-next tracks next by hand because a config that lints for a different version of the framework is worse than no config. The four trigger.dev packages have to match the version of the worker that is actually deployed, so a caret there is a deploy-time surprise rather than an install-time one. @duckdb/node-api is a native addon whose version string has an -r.1 on it, which is not a range anybody should widen casually, and recharts is pinned because one of our two files that touch it does import * as RechartsPrimitive from "recharts" plus a TooltipValueType type import, which is a much wider surface than a handful of named components and exactly the surface that moves in a minor.
Everything else is a caret, and the thing that makes carets tolerable is not courage, it is the lockfile.
The supply-chain settings are five lines about builds and five about waiting
pnpm-workspace.yaml in this repo declares no workspaces at all. It exists for two other reasons:
allowBuilds:
'@depot/cli': true
core-js: false
esbuild: true
sharp: false
unrs-resolver: false
minimumReleaseAgeExclude:
- '@trigger.dev/core@4.7.2'
- '@trigger.dev/sdk@4.7.2'
- '@trigger.dev/build@4.7.2'
- '@trigger.dev/schema-to-json@4.7.2'
- trigger.dev@4.7.2
allowBuilds is pnpm's answer to postinstall scripts: nothing runs unless it is named. Two packages are allowed to run a build and three are explicitly refused. sharp: false is the interesting refusal. Next uses sharp for image optimisation, the platform we deploy to supplies its own, and our public pages carry no bitmap images at all, so nothing in a local build needs to spend a minute compiling it.
minimumReleaseAgeExclude is the other half of a rule I like a lot: new versions of a package are not installable until they have existed for a while, which is the cheapest defence there is against a compromised publish. The list is the exceptions, and every exception is a trigger.dev package at exactly 4.7.2, because a worker version has to go out the day you need it rather than the day a waiting period ends. Five lines in a config file is a strange place for a security posture to live, and it is a better place than a wiki.
There is a second package.json, and it disagrees with this one
The Instagram and TikTok reels are rendered with Remotion out of reels/, which has its own package.json, its own lockfile and its own workspace root:
# reels/pnpm-workspace.yaml
# Its own workspace root, so the reels' dependencies stay out of the app's lockfile.
Seven runtime dependencies and 292 packages in that lockfile, against 1,093 in the app's. The split is deliberate: the app ships to a serverless platform where install time and bundle resolution matter, and a video renderer that drags in platform-specific compositor binaries for five operating systems has no business in that tree. Its minimumReleaseAgeExclude list is 31 lines long, one per @remotion/* package, because they all move together on every release.
The two projects do not agree about everything:
$ node -e "console.log(require('./package.json').dependencies.zod, require('./reels/package.json').dependencies.zod)"
^4.6.5 4.5.4
The app takes any zod 4; the reels pin 4.5.4, which is what was current when the compositions' schemas were written, and nothing since has made me want to move it. They do agree about react and react-dom at exactly 19.2.8, and they have to: the drawings in the reels are the app's own React components, imported across the boundary, so two Reacts would be two Reacts.
What the audit changed
One deletion (next-themes), one note to self (grep the CSS, not just the TypeScript), and one thing I now think is the most useful line in the whole file:
"packageManager": "pnpm@11.18.0"
Because every number in this post is only true if everybody, including the deploy, resolves the same tree. Nine exact pins protect nine packages. That one line protects the other 1,084.
Top comments (0)