01
January 8, 2026

How I Build an npm Package in 2026

TypeScriptnpmDeveloper ExperienceToolingBest Practices

A few years ago, shipping a TypeScript package to npm felt like assembling furniture without instructions. You'd spend the first day configuring tools, the second day fighting ESM/CJS compatibility, and maybe—if you were lucky—the third day actually writing code.

I've shipped enough packages now to know what matters and what doesn't. Spoiler: most of what I used to configure manually was a waste of time.

What Changed

In 2023, my typical package setup involved: TypeScript with a custom tsconfig, ESLint with Prettier, Jest for testing, Rollup or esbuild for bundling, semantic-release for versioning, and a handful of shell scripts to glue everything together. Seven tools minimum, each with their own configuration file, their own update cycle, their own breaking changes.

I don't do that anymore.

The shift happened gradually. I started noticing a pattern: the projects I maintained longest were the ones with the fewest configuration files. The projects that rotted fastest were the ones where I'd spent days perfecting the "ideal" setup.

Configuration is debt. Every config file is a future merge conflict, a potential breaking change, a thing you'll have to remember when you revisit the project in six months.

The Tools I Actually Use

My current stack for npm packages:

ToolPurpose
tsdownBuild ESM and CJS with types
BiomeLinting and formatting
VitestTesting with coverage
ChangesetsVersioning and changelogs
LefthookGit hooks

That's it. Five tools. Most of them need less than ten lines of configuration.

The consolidation principle I learned from Python's Ruff applies here too: if one tool can do what three tools did, use the one tool. Biome replaced ESLint plus Prettier for me. It's faster, the config is smaller, and I stopped having arguments with myself about semicolons.

tsdown is the build tool I wish I'd found earlier. Eight lines of config:

typescript
import { defineConfig } from "tsdown";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm", "cjs"],
  dts: true,
  clean: true,
});

Dual output, type declarations, done. I don't think about bundling anymore.

What I Refuse to Configure

Strictness. My TypeScript config extends @tsconfig/strictest and I don't relax it. Ever. I've been bitten too many times by "temporarily" disabling strict null checks and then shipping a bug that would've been caught at compile time.

Coverage thresholds. 80% minimum, enforced in CI. Not because 80% is magical, but because having a floor prevents the slow decay where coverage drops 1% per month until you're at 40% and afraid to touch anything.

JSDoc for public APIs. ESLint enforces this. Every exported function needs documentation. Not because I love writing docs—I don't—but because future me will forget what the function does, and the compile-time error is easier to deal with than the runtime confusion.

Import sorting. Biome handles this automatically. I don't care what order the imports are in. I care that they're consistent. Automation means I never think about it.

The common thread: anything that can be automated should be automated. Anything that can be enforced should be enforced. The less I have to remember, the less I forget.

What I Intentionally Don't Support

Anything below Node 20. Maintaining compatibility with older Node versions isn't free. Every polyfill is complexity. Every version matrix is CI time. I pick a floor and stick with it.

Monorepos from day one. I went through a phase where everything had to be a monorepo with shared packages and workspace protocols. It was intellectually satisfying and practically miserable. Now I start with a single package. If it genuinely needs to split, I split it. Most packages never need to split.

Custom build pipelines. If tsdown can't handle it, I reconsider whether the package should exist in that form. Ejecting into custom esbuild or Rollup configs has never once improved my life in the long run.

Optional strictness. I've seen codebases with "strict mode optional" and watched as every new contributor disabled another check because it was inconvenient. Strictness is the default or it doesn't exist.

These aren't universal principles. They're my principles, for my projects, based on what I've learned maintaining code over years. Your constraints might be different.

The Maintenance Test

Setup time is a trap. It's easy to obsess over the perfect initial configuration because that's the part that feels productive. But most of a package's life is maintenance: dependency updates, bug fixes, occasional features.

I evaluate tooling choices by asking: what happens in two years?

  • Will this tool still exist? (Biome, Vitest: likely. Random ESLint plugin with 200 GitHub stars: maybe not.)
  • Will the config still work after major version bumps? (Fewer config files means fewer breaking changes to manage.)
  • Can someone else contribute without studying the build system? (If the README needs a section explaining the build process, something is wrong.)

The packages I'm proudest of are the ones where I can merge a PR without thinking about infrastructure. The build just works. The tests just run. The release just publishes.

Speed Matters More Than I Thought

I used to think build speed was a nice-to-have. Now I think it's essential.

Fast builds mean faster feedback loops. Faster feedback loops mean I catch mistakes earlier. Catching mistakes earlier means I ship fewer bugs. It's not about saving thirty seconds—it's about staying in flow.

Biome lints my codebase in milliseconds. Vitest runs tests faster than Jest ever did. tsdown builds in under a second. The cumulative effect is that I actually run these tools. When the lint took ten seconds, I'd skip it "just this once." When it takes 200ms, I run it on every save.

This is why I prefer Rust-based tools when they exist. Not because Rust is trendy, but because the speed difference changes my behavior.

The Single Export Surface

Every package I build has one entry point: src/index.ts. Everything public gets exported from there. Everything internal stays internal.

This constraint seemed limiting at first. What if I need multiple entry points? What about tree-shaking optimization?

In practice, it simplifies everything. Consumers know exactly where to import from. The public API is visible in one file. Breaking changes are obvious because they require touching index.ts.

When a package genuinely needs multiple entry points, that's usually a sign it should be multiple packages. I've been wrong about this exactly once in the last three years.

What I'm Still Figuring Out

Documentation beyond JSDoc. I use VitePress for some packages, but the maintenance burden is real. Auto-generated API docs from types are never quite good enough. Hand-written guides get stale. I haven't found the right balance yet.

Changelogs. Changesets generate them automatically, which is better than nothing, but the output is mechanical. Good changelogs tell a story. I'm not sure how to automate that.

Monorepo avoidance has limits. Some projects genuinely benefit from shared tooling and atomic commits across packages. I just haven't found a monorepo setup that doesn't feel like fighting the tools.

The Point

Building npm packages in 2026 is easier than it was in 2020, but only if you resist the urge to configure everything. The best setup is the one you don't think about.

My approach: strict defaults enforced by fast tools, minimal configuration, aggressive consolidation. Five dependencies for the dev toolchain. One tsconfig. One linter config. Build, test, ship.

It's not clever. It won't win any awards. But the packages ship, they work, and I can still understand them a year later.

That's enough.


What does your package setup look like these days? I'm always curious whether others have landed on similar conclusions or found completely different approaches that work. Though, fair warning—this blog still doesn't have comments. 555