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"
}
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
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"]
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
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"]
⚛️ 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
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);
});
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
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>;
}
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?
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
<StackContainer offset={32} scaleStep={0.07} fadeStep={0.35}>
{chapters.map((c) => (
<StackCard key={c.id}>{c.title}</StackCard>
))}
</StackContainer>
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.
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)