PYYUPSK

a self-taught dev from Thailand

Pongsakorn Thipayanate · Samutsakhon, TH ·webring

tutorial

My Pragmatic Approach to Building npm Packages in 2026

I use five tools, minimal config, and strict defaults. This is the npm package setup I actually maintain.

view .mdopen in claudeopen in chatgpt

A few years ago, shipping a TypeScript package to npm felt like assembling furniture with no instructions. You spent the first day configuring tools. You spent the second day fighting ESM and CJS compatibility. If you were lucky, you spent the third day writing code.

I have shipped enough packages now to know what matters and what does not. Most of what I used to configure by hand was a waste of time.

What Changed

In 2023, my typical package setup used TypeScript with a custom tsconfig, ESLint with Prettier, Jest for testing, Rollup or esbuild for bundling, and semantic-release for versioning. I also wrote several shell scripts to connect everything. That was seven tools at minimum. Each tool had its own configuration file, its own update cycle, and its own breaking changes.

I do not do that anymore.

The shift happened gradually. I noticed a pattern. The projects I maintained longest had the fewest configuration files. The projects that rotted fastest were the ones where I had spent days perfecting the ideal setup.

Configuration is debt. Every config file can become a future merge conflict or a breaking change. It is also something you must remember when you return to the project in six months.

The Tools I Actually Use

The current tools in my stack for npm packages:

Tool Purpose
tsdown Build ESM and CJS with types
Biome Linting and formatting
Vitest Testing with coverage
Changesets Versioning and changelogs
Lefthook Git hooks

That is the whole stack: five tools. Most of them need fewer than ten lines of configuration.

I learned a consolidation principle from Python’s Ruff: if one tool can do what three tools did, use the one tool. Biome replaced both ESLint and Prettier for me. It is faster, the config is smaller, and I stopped arguing with myself about semicolons.

tsdown is the build tool I wish I had found earlier. It needs only eight lines of config:

import { defineConfig } from "tsdown";

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

It builds dual output with type declarations, and that is all I need. I no longer think about bundling.

What I Refuse to Configure

Strictness: My TypeScript config extends @tsconfig/strictest, and I never relax it. I have been bitten too many times after temporarily disabling strict null checks. Each time, I later shipped a bug that compile-time checking catches early.

Coverage thresholds: I enforce a minimum of 80% in CI. This number is not magic. A floor like this prevents slow decay, where coverage drops by 1% each month until it reaches 40% and nobody wants to touch the code.

JSDoc for public APIs: ESLint enforces this rule. Every exported function needs documentation. I do not enjoy writing docs, but I will forget what the function does later. A compile-time error is easier to fix than runtime confusion.

Import sorting: Biome handles this automatically. The order of imports does not matter to me. Consistency matters. Automation means I never think about it.

The common thread is this: automate what you can, enforce what matters, and forget the rest.

What I Choose Not to Support

Node versions below 20: Supporting older Node versions is not free. Every polyfill adds complexity. Every version matrix adds CI time. I pick a minimum version and keep it.

Monorepos from day one: I once went through a phase where every project had to be a monorepo with shared packages and workspace protocols. This felt satisfying to design but was miserable to use. Now I start with a single package. If it truly needs to split later, I split it then. Most packages never need to split.

Custom build pipelines: If tsdown cannot handle a package, I reconsider whether the package needs a different form entirely. Switching to a custom esbuild or Rollup config has never improved my life in the long run.

Optional strictness: I have seen codebases with strict mode marked as optional. Each new contributor disabled another check because it felt inconvenient. Strictness must be the default, or it does not exist at all.

These are not universal rules. They are my rules, for my projects, based on years of maintaining code. Your constraints differ from mine.

The Maintenance Test

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

I judge tooling choices by asking one question: what happens in two years?

  • Will this tool still exist? Biome and Vitest likely will. A niche ESLint plugin with 200 GitHub stars often does not survive that long.
  • Will the config still work after a major version bump? Fewer config files mean fewer breaking changes to manage.
  • Can someone else contribute without studying the build system? If the README needs a section that explains the build process, something is wrong.

The packages I am proudest of are the ones where I can merge a pull request without thinking about infrastructure. Build, test, and release all run on autopilot.

Speed Matters More Than I Thought

I used to think build speed was optional. Now I think it is essential.

Fast builds let me catch mistakes before they cost real time. This is not about saving thirty seconds. It is about staying in flow.

Biome lints my codebase in milliseconds. Vitest runs tests faster than Jest ever did. tsdown builds in under a second. Because of this speed, I actually run these tools. When linting took ten seconds, I skipped it just this once. Now that it takes 200 milliseconds, I run it on every save.

This is why I prefer Rust-based tools when they exist. Rust is not trendy to me. The speed difference changes my behavior, and that is what matters.

The Single Export Surface

Every package I build has one entry point, src/index.ts. I export everything public from there. Everything internal stays internal.

This constraint seemed limiting at first. I worried about needing multiple entry points. I also worried about tree-shaking, the process that removes unused code from the final bundle.

In practice, one entry point simplifies everything. Consumers know exactly where to import from. The public API stays visible in one file. Breaking changes become obvious because they require a change to index.ts.

When a package truly needs multiple entry points, this usually means it must become multiple packages instead. I have been wrong about this only once in the last three years.

What I Am Still Figuring Out

I still have not solved documentation beyond JSDoc. I use VitePress for some packages, but the maintenance work is real. Auto-generated API docs built from types are never quite good enough. Hand-written guides go stale. I have not found the right balance yet.

Changelogs are another open question. Changesets generate them automatically, which beats nothing, but the output reads mechanically. Good changelogs tell a story, and I do not yet know how to automate that.

Monorepo avoidance has limits too. Some projects truly benefit from shared tooling and atomic commits across packages. I have not yet found a monorepo setup that does not 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 stop thinking about.

My approach uses strict defaults enforced by fast tools, minimal configuration, and aggressive consolidation. I keep five dev dependencies, one tsconfig, and one linter config. Then I build, test, and ship.

This approach is not clever. It will not win any awards. But the packages ship, they work, and I can still understand them a year later.

← All writings