Create a Modern npm Package in 2026: Complete Guide
Learn how to build and publish a modern npm package in 2026 with TypeScript, tsup, and best practices that actually matter. Skip the over-configuration trap.
While I was looking over some npm packages the other day, I realized how dramatically the landscape has changed since I published my first package back in 2019. I was once guilty of spending three days configuring webpack, babel, and a dozen plugins just to export a simple utility function. Little did I know that in 2026, we'd have tools that make this process almost trivial—if you resist the urge to over-engineer everything.
The truth is, building npm packages in 2026 is easier than ever, but only if you know which tools to use and which temptations to avoid. Let me show you how I build modern packages today.
Why Building npm Packages in 2026 Is Different
When I finally decided to revisit my old packages, I was shocked at the complexity I had created for myself. Configuration files everywhere, build scripts that nobody understood, and a publishing process that felt like performing surgery.
The modern approach is fundamentally different. We now have:
- Zero-config bundlers that understand TypeScript natively
- Package.json exports that replace convoluted build outputs
- Provenance tracking built into npm itself
- Automated version management that doesn't require manual changelog updates
The biggest shift? We've moved from "configure everything" to "configure only what's necessary." This means you can go from idea to published package in under an hour, not three days.
The Modern npm Package Stack: Tools That Actually Matter
Let me be direct about the tools I actually use in production today. I cannot stress this enough: you don't need a complex setup to build a great package.
Here's my stack:
- tsup for bundling (replaces webpack, rollup, and all that configuration)
- TypeScript for type safety and DX
- Vitest for testing (faster than Jest, better DX)
- Biome for linting and formatting (replaces ESLint + Prettier)
- Changesets for version management
- pnpm for package management (faster, more efficient)
Notice what's missing? No webpack configs, no babel, no complex ESLint setups. The tools have gotten good enough that we don't need to configure everything manually anymore.

Setting Up Your Package with tsup and TypeScript
Let's build a real package. Not a hello world example, but something you might actually publish—a utility for safely parsing JSON with validation.
First, initialize your project:
// package.json
{
"name": "safe-json-parse",
"version": "0.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"test": "vitest",
"lint": "biome check .",
"prepublishOnly": "pnpm build && pnpm test"
},
"devDependencies": {
"@biomejs/biome": "^1.8.0",
"tsup": "^8.2.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
}
}Notice how simple this is? The exports field handles both ESM and CommonJS automatically. The build script is one line. When I first saw this pattern, I realized we'd been over-complicating things for years.
Now for the actual package code:
// src/index.ts
export interface ParseResult<T> {
success: true;
data: T;
} | {
success: false;
error: Error;
}
export function safeJsonParse<T = unknown>(
input: string,
validator?: (data: unknown) => data is T
): ParseResult<T> {
try {
const parsed = JSON.parse(input);
if (validator && !validator(parsed)) {
return {
success: false,
error: new Error('Validation failed')
};
}
return {
success: true,
data: parsed as T
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error(String(error))
};
}
}
// Type guard example for users
export function isUser(data: unknown): data is { name: string; email: string } {
return (
typeof data === 'object' &&
data !== null &&
'name' in data &&
'email' in data &&
typeof (data as any).name === 'string' &&
typeof (data as any).email === 'string'
);
}This is a real-world example I actually use. The pattern of returning { success, data } or { success, error } is wonderful because it forces consumers to handle errors explicitly. No more try-catch blocks scattered everywhere.
Package.json Configuration: Exports, Types, and Module Fields
Let's talk about the exports field, because I was confused about this for months when it first came out. In other words, understanding this one field properly will save you from countless bug reports about "package not working in my setup."
The exports field controls how your package is imported. Here's what each part does:
- "import": Used when someone does
import { safeJsonParse } from 'safe-json-parse' - "require": Used when someone does
const { safeJsonParse } = require('safe-json-parse') - "types": Points to TypeScript declaration files for each format
The order matters! Types must come first in each condition, otherwise TypeScript won't find them properly. I learned this the hard way after shipping a package where types "randomly" stopped working for some users.
Also notice the file extensions: .js and .cjs for JavaScript, .d.ts and .d.cts for types. The c prefix explicitly marks CommonJS format, which helps bundlers optimize better.

Testing, Linting, and Security Checks in Your CI Pipeline
Here's where many developers go overboard. You don't need a 200-line CI config to build a reliable package. Luckily we can keep it simple and effective.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
threshold: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}
}
});// src/index.test.ts
import { describe, it, expect } from 'vitest';
import { safeJsonParse, isUser } from './index';
describe('safeJsonParse', () => {
it('parses valid JSON', () => {
const result = safeJsonParse('{"name":"Chris"}');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual({ name: 'Chris' });
}
});
it('handles invalid JSON', () => {
const result = safeJsonParse('invalid json');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeInstanceOf(Error);
}
});
it('validates with custom validator', () => {
const input = '{"name":"Chris","email":"chris@example.com"}';
const result = safeJsonParse(input, isUser);
expect(result.success).toBe(true);
});
it('fails validation when data does not match', () => {
const input = '{"name":"Chris"}'; // missing email
const result = safeJsonParse(input, isUser);
expect(result.success).toBe(false);
});
});Your CI pipeline should run three commands: pnpm lint, pnpm test, and pnpm build. That's it. Don't add complexity until you need it.
Publishing Strategies: Provenance, Changesets, and Semantic Versioning
When I came across npm's provenance feature, I realized how much the security landscape had matured. Provenance creates a cryptographic link between your package, the code in your repository, and the build that created it. Users can verify your package wasn't tampered with.
To enable provenance, you just add --provenance to your publish command. But I recommend using changesets instead of manual publishing:
// .changeset/config.json
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}Now your workflow becomes:
- Make changes to your code
- Run
pnpm changesetto describe the change - Commit both code and changeset
- CI automatically creates a PR with version bump
- Merge PR, CI publishes with provenance automatically
This eliminates human error in versioning. I was once guilty of publishing three patches in a row because I forgot to bump versions correctly. Never again.
What NOT to Do: Avoiding Over-Configuration and Common Pitfalls
Let me show you what I see developers doing wrong all the time.
DON'T do this:
// Bad: Complex webpack config for a simple package
module.exports = {
entry: './src/index.ts',
output: {
library: {
name: 'MyPackage',
type: 'umd',
export: 'default'
},
globalObject: 'this'
},
module: {
rules: [
{
test: /\.ts$/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }]
]
}
},
'ts-loader'
]
}
]
},
// ... 50 more lines
};DO this instead:
// Good: Simple tsup config (or none at all!)
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
clean: true
});The first example is what I used to do. It took hours to configure and broke constantly. The second example does the same thing in five lines and never breaks.
Other pitfalls to avoid:
- Publishing your entire repository (use the files field!)
- Not testing in both ESM and CommonJS environments
- Forgetting to add a LICENSE file
- Publishing without running tests (use prepublishOnly!)
- Not including TypeScript types
Ship It: From Local Development to npm Registry
You've built your package, written tests, configured everything correctly. Now it's time to ship.
First, test your package locally by linking it:
# In your package directory
pnpm link --global
# In a test project
pnpm link --global safe-json-parseMake sure it actually works in a real project before publishing. I've caught so many issues this way—path problems, missing exports, incorrect types.
When you're ready to publish:
# Make sure you're logged in
npm login
# Publish with provenance
npm publish --provenance --access publicOr if you're using changesets (which I recommend):
# Create a changeset
pnpm changeset
# Version and publish (usually done by CI)
pnpm changeset version
pnpm changeset publishWatch your package appear on npmjs.com. Check the provenance badge—it should show a verified link to your GitHub repository. Wonderful!
And that concludes the end of this post! I hope you found this valuable and look out for more in the future! Building npm packages in 2026 doesn't have to be complicated. Focus on the tools that matter, avoid over-configuration, and ship quality code. The JavaScript ecosystem is better when we all contribute useful packages—so go build something and put it out there.