I maintain four small React libraries: react-loadly, accessly, compressly and tonely. This month I found three bugs in them that my own test suites never caught. They only showed up in other people's projects.
Here they are, with the check I now run before every publish.
1. The missing "use client"
The symptom. Someone imports <PermissionProvider> in a Next.js App Router layout and gets:
TypeError: createContext is not a function
The cause. In the App Router, every file is a Server Component unless it says otherwise. My React entry used createContext and hooks, but nothing in the published file marked it as client code. So Next.js ran it on the server, where those APIs don't exist.
The fix. The built file has to start with the directive:
"use client";
Putting it in your source isn't enough. Bundlers often merge modules and drop directives, and minifiers can strip them too. Add it as a banner in your bundler config (with terser, use format.preamble so it survives minification), and only on the entries that really need the client. Split anything that works without React into its own entry. For accessly that became accessly/core, which Server Components and Route Handlers can import safely.
The check.
head -c 20 dist/react/index.js # must print "use client";
Then render a component from the package in a fresh create-next-app project, inside a server layout.
2. require() returned an empty object
The symptom. In Jest or an older SSR setup:
const { SkeletonLoader } = require("react-loadly"); // undefined
The cause. The package had "type": "module", and the CommonJS build was written to index.js. Node decides the format of a .js file from the nearest package.json, so it treated my CommonJS file as an ES module, and require() got nothing useful.
The fix. Give CommonJS files the .cjs extension and their types .d.cts, then point each condition at the right file:
"exports": {
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
}
},
"main": "./dist/index.cjs"
The check.
node -e "console.log(Object.keys(require('./dist/index.cjs')))"
npx @arethetypeswrong/cli --pack .
npx publint
The first line must print your export names. The other two catch wrong types and wrong exports maps across node16, bundler and CommonJS resolution.
3. A 4 MB package for 4 kB of code
The symptom. No error at all, just a package that took 4 MB on every install.
The cause. Without a files field, npm publishes everything that isn't ignored. Mine included the docs site's images. Another package published a full copy of its type declarations in every subpath folder, which turned 32 files into 972.
The fix. An explicit allowlist:
"files": ["dist", "!dist/types", "AGENTS.md", "llms.txt"]
The check.
npm pack --dry-run
Read the file list every time. accessly went from 4 MB to 128 kB, and react-loadly from 972 files to 32.
The habit that ties it together
All three bugs lived in the gap between my repo and a real install. My tests imported the source. My users installed a tarball. So now, before every publish, I:
- Run
npm packand install the.tgzinto a scratch Next.js app and a scratch CommonJS project with Jest. - Render one component from a server layout, and
require()the package from Jest. - Run
publintand@arethetypeswrong/cli.- Read thenpm pack --dry-runfile list.
It takes ten minutes, and it would have caught every one of these.
A bonus for AI coding agents
While fixing these, I also added an AGENTS.md file to each package, with the exact API, when to use the library and the mistakes to avoid. Coding agents read it from node_modules, so they stop guessing method names. If you maintain a package, it's worth an afternoon.
The libraries, if you want to see the setup:
-
react-loadly: loaders, skeletons and the new
Skeletonize - accessly: permissions for React and Next.js
- compressly: image compression before upload
- tonely: UI sounds without audio files
Top comments (0)