Skip to main content
react

Package.json Is a Product Surface

package.json communicates quality and compatibility promises before anyone reads a line of code. Here's what a well-crafted one says about the author.


5 min read

Open any well-maintained npm package and read its package.json before touching the readme. In ten seconds you can tell whether someone took the library seriously. The name, description, exports field, peerDependencies, engines, and sideEffects flag together form a set of compatibility promises to every developer who installs it. Most library authors treat these fields as boilerplate. The ones who treat them as product decisions produce better packages.


The name and description are your first impression

A package named react-cool-dropdown tells you nothing. A package named @radix-ui/react-select tells you the scope (radix-ui), the target environment (react), and the primitive (select). The description field follows the same logic. "A dropdown" is useless. "An accessible, unstyled select primitive for React" tells me what problem it solves and what it intentionally does not do.

The description shows up in npm search, in your node_modules, in bundle analyzers, in SBOM outputs. It is the package's only marketing copy in most tool contexts. Write it like one sentence of real documentation.

The exports field is a tree-shaking contract

The exports field replaced main as the correct way to declare what a package exposes. Bundlers like webpack 5, Rollup, and Vite all read it. Get it wrong and your users import 40kb when they wanted 2kb.

A real example from a well-structured component library:

{
  "name": "@acme/ui",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./button": {
      "import": "./dist/button.mjs",
      "require": "./dist/button.cjs",
      "types": "./dist/button.d.ts"
    },
    "./dialog": {
      "import": "./dist/dialog.mjs",
      "require": "./dist/dialog.cjs",
      "types": "./dist/dialog.d.ts"
    }
  },
  "sideEffects": false
}

The subpath exports (./button, ./dialog) let bundlers eliminate unused components at build time. Without them, import { Button } from '@acme/ui' may pull in the entire library. With them, a bundler that respects the exports field can trace exactly what shipped.

The "import" condition targets ESM consumers, "require" targets CJS. The "types" condition wires up TypeScript without needing typesVersions hacks. This is not optional — it is the contract that makes the package usable in modern toolchains.

sideEffects: false is a statement of intent

"sideEffects": false tells bundlers that importing any module from this package will not cause observable changes to global state. No globals registered. No polyfills injected. No CSS injected at import time. If your package does have side effects — a CSS file, a global event listener on import — you declare them explicitly:

{
  "sideEffects": ["./dist/styles.css", "./src/setup.js"]
}

Omitting sideEffects entirely leaves dead code elimination to chance. Bundlers default to assuming everything has side effects when the field is absent.

peerDependencies are compatibility promises, not formalities

A package that lists "react": ">=16.8.0" in peerDependencies is making a claim: this package works with any React version from 16.8 forward. If it actually only works with React 18 due to use of useSyncExternalStore, that's a broken promise. Users hit runtime errors, wonder what went wrong, and file issues.

Tight peer ranges ("react": "^18.0.0") are more honest when they reflect reality. The peerDependenciesMeta field lets you mark peers as optional:

{
  "peerDependencies": {
    "react": ">=17",
    "@types/react": ">=17"
  },
  "peerDependenciesMeta": {
    "@types/react": {
      "optional": true
    }
  }
}

This is the pattern used by Radix UI — it does not force TypeScript users to install @types/react separately, but also does not penalize JS-only projects with a spurious warning.

The engines field is the honest version of "requirements"

"engines": { "node": ">=18.0.0" } stops users from opening a GitHub issue saying "it doesn't work" when they're running Node 14. It is a machine-readable version of the first line of your README's requirements section, and most developers skip reading that.

Package managers respect it differently — npm warns, yarn enforces by default, pnpm enforces. But the field is indexed by registries and surfaces in tooling like Renovate and Dependabot, which can filter upgrades based on engine compatibility. It is worth setting correctly.

What a well-crafted package.json signals

When all of these fields are filled in correctly — a clear name, a one-sentence description, subpath exports with ESM and CJS, sideEffects: false, accurate peer ranges, an engines field — what it signals is that the author thought about the people installing this. They considered the bundler, the TypeScript user, the monorepo with strict peerDependency enforcement, the developer reading the npm search results at 2am.

Package.json is not boilerplate. It is the first API surface anyone who uses your package interacts with. Treat it like one.