DEV Community

Cover image for My 4KB React package was installing 116 dependencies. Here's what I found when I audited it.
Saad Ahmad
Saad Ahmad

Posted on

My 4KB React package was installing 116 dependencies. Here's what I found when I audited it.

I published a small React component to npm. Four kilobytes. One job: stack cards as you scroll.

Then I sat down and actually audited it — the way you'd audit someone else's package before adding it to production.

It was installing 116 packages onto every machine that ran npm install.

Here's everything that was wrong, why each one mattered, and how to check your own packages tonight. 🔍


🚨 Bug 1: I was shipping a bundler to my users

This was in my package.json:

"dependencies": {
  "rollup": "^4.40.1",
  "rollup-plugin-postcss": "^4.0.2",
  "@types/react": "^19.1.2"
}
Enter fullscreen mode Exit fullscreen mode

Those are build tools. They belong in devDependencies. Sitting in dependencies, npm faithfully installed all three — plus their transitive deps — into every consuming project.

I measured it. Installing that dependency set pulls 116 packages. My actual library is one file.

The fix is a two-second edit. The lesson is that nobody ever looks:

npm view your-package dependencies
Enter fullscreen mode Exit fullscreen mode

Run that on your own packages right now. If a build tool comes back, that's a bundler on someone else's laptop. 😬


💥 Bug 2: React's JSX runtime was baked into my bundle

My rollup.config.js had this:

external: ["react", "react-dom"]
Enter fullscreen mode Exit fullscreen mode

Looks right. It isn't.

My tsconfig.json used "jsx": "react-jsx", so every component compiled to an import from react/jsx-runtime — a different specifier than react. It wasn't in external, so Rollup happily inlined it.

Worse: it inlined the development build, which contains this:

process.env.NODE_ENV
Enter fullscreen mode Exit fullscreen mode

process doesn't exist in a browser. Anyone importing my package through plain ESM or certain Vite configs got a hard crash: process is not defined.

I had rollup-plugin-peer-deps-external installed to prevent exactly this. But I'd never declared any peerDependencies — so it read an empty list and did nothing. A safety net bolted to the ceiling. 🪤

// what it should have been
external: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"]
Enter fullscreen mode Exit fullscreen mode

⚛️ Bug 3: No "use client", so Next.js rejected it

My components use hooks. Under the Next.js App Router, that makes them client components — and they must say so.

Mine didn't. Anyone on modern Next.js hit an error immediately.

The fix is one line at the top of the built bundle. But here's the part that nearly bit me twice: esbuild strips module-level directives when it bundles, and tsup's banner option doesn't survive minification.

I only caught it because I checked the built file instead of trusting the config:

head -c 20 dist/index.js
Enter fullscreen mode Exit fullscreen mode

Now a post-build script adds the directive, and a test asserts it's there. Which brings me to the thing I'd most want you to take away. 👇


🧪 The real lesson: test the artifact, not the source

Every bug above lived in dist/, not src/. My source code was fine. Unit tests passing means nothing if the thing you ship is broken.

So I wrote tests that read the built output:

it('never references process.env', () => {
  expect(esm).not.toContain('process.env');
});

it('leaves the JSX runtime external', () => {
  expect(esm).toMatch(/from\s*["']react\/jsx-runtime["']/);
});

it('opens with the use client directive', () => {
  expect(esm.startsWith('"use client";')).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

Every one of those exists because the previous release shipped that exact mistake. They're regression tests for bugs I'd already inflicted on people.

Two tools do most of this for you, and both take under a minute:

npx publint          # catches malformed exports, wrong entry points
npx @arethetypeswrong/cli --pack .   # catches broken TypeScript resolution
Enter fullscreen mode Exit fullscreen mode

If you maintain a package and have never run these, go run them. 🎯


🔎 Bug 4: Zero keywords, zero downloads

My npm page said Keywords: none.

npm search is largely keyword-driven. No keywords means you are effectively unlisted. My weekly downloads were zero, and I'd assumed the idea was unpopular. It was just invisible.

My description also read "Reuseable Stack on scroll librabry" — two typos in the single line that shows up in search results. 🙈


🐛 Bug 5: The one my tests couldn't catch

After shipping v2, I rebuilt my demo site on it — and the build failed with a TypeScript error.

My hook returned RefObject<T | null>. React 19's types accept that. React 18's don't, because React 18 treats RefObject as covariant.

My package claimed "react": ">=18". For every TypeScript user on React 18, it didn't compile.

My CI had a React 18 job. It ran the test suite. But tests don't typecheck against React 18 — Vitest ran against whatever types were installed. The job was theater.

The fix was a compile-only fixture:

// test/types/consumer.tsx — never runs, just has to compile
export function PassesHookRefToCard() {
  const ref = useStackProgress<HTMLElement>({ onProgress: (p) => p.toFixed(2) });
  return <StackCard ref={ref}>first</StackCard>;
}
Enter fullscreen mode Exit fullscreen mode

Plus CI that installs the matching @types/react for each React major and runs tsc. I verified it works by reverting the fix — the error came right back.

If you support a version range, prove it. Don't just declare it.


✅ The 10-minute audit for your own packages

npm view your-package dependencies   # build tools in here? move them
npm view your-package keywords       # empty? nobody can find you
npm publish --dry-run                # see exactly what ships
npx publint                          # manifest correctness
npx @arethetypeswrong/cli --pack .   # TypeScript resolution
head -c 20 dist/index.js             # is "use client" actually there?
Enter fullscreen mode Exit fullscreen mode

That's it. Six commands. They'd have caught all five of my bugs.


📦 Where the package landed

stack-on-scroll is now:

  • Zero dependencies. 4 packages install total, versus 116 before
  • Zero config — no stylesheet to import, styles are inline
  • Zero JavaScript on the default path — the stacking is pure position: sticky
  • Opt-in scale, fade, and tilt, all sharing one animation frame
  • Full TypeScript types, verified against React 18 and 19
  • 58 tests, including ones that read the built artifact
npm install stack-on-scroll
Enter fullscreen mode Exit fullscreen mode
<StackContainer offset={32} scaleStep={0.07} fadeStep={0.35}>
  {chapters.map((c) => (
    <StackCard key={c.id}>{c.title}</StackCard>
  ))}
</StackContainer>
Enter fullscreen mode Exit fullscreen mode

No index prop — the container counts its own children.

🔗 Live demo with sliders for every prop
🐙 Source on GitHub


👋 About me

I'm Saad Ahmad, a freelance frontend developer. I build React and Next.js interfaces, and — as you can probably tell — I enjoy the unglamorous part: the packaging, the build config, the reason your bundle is 400KB when it should be 40.

If your team is shipping a component library, fighting a slow build, or has an npm package nobody can find, that's the kind of work I like. I'm currently open to freelance projects.

📮 isaadahmad.com


Found a bug in your own package after reading this? I'd genuinely love to hear about it in the comments — misery loves company. 😄

Top comments (0)