NPM Packages 101: The Ultimate Guide
Every JavaScript developer uses npm packages. Very few know how to build one. That changes today.
NPM (Node Package Manager) hosts over 2 million packages with billions of downloads weekly. Whether you want to share a utility with the world, distribute internal tools at your company, or build an open-source library — knowing how to create and publish npm packages is a superpower.
This guide walks you through everything — from your first package to production-grade publishing.
Table of Contents
- Why Build NPM Packages?
- How NPM Works Under the Hood
- Project Setup — The Right Way
- package.json — Every Field Explained
- Building Your First Package
- Supporting ESM and CommonJS
- Adding TypeScript Support
- Testing Your Package
- Publishing to NPM
- Versioning — SemVer Done Right
- Monorepos & Workspaces
- Scoped & Private Packages
- Automating Releases with CI/CD
- Making People Actually Use Your Package
- Common Mistakes
Why Build NPM Packages?
- Code reuse — Stop copy-pasting utilities between projects
- Team productivity — Shared internal tools as packages
- Open source credibility — A published package looks great on your profile
- Products — Some npm packages are businesses (Tailwind CSS, Prisma, etc.)
- Learning — Nothing teaches you JavaScript internals like building a library
How NPM Works Under the Hood
When you run npm install lodash, here's what happens:
1. npm reads package.json → finds "lodash" in dependencies
2. Queries the npm registry (registry.npmjs.org) for the package metadata
3. Resolves the version using semver ranges (~, ^, exact)
4. Downloads the tarball (.tgz) from the registry
5. Extracts into node_modules/lodash/
6. Installs lodash's dependencies recursively (dependency tree)
7. Updates package-lock.json with exact resolved versions
npm registry (registry.npmjs.org)
│
▼
┌──────────────┐ resolve ┌──────────────┐
│ package.json │───────────────▶│ package-lock │
│ "lodash": │ versions │ .json │
│ "^4.17.0" │ │ "4.17.21" │
└──────────────┘ └──────┬───────┘
│ download
▼
┌──────────────────┐
│ node_modules/ │
│ └── lodash/ │
│ ├── index.js│
│ └── ... │
└──────────────────┘
Project Setup — The Right Way
# Create a new directory
mkdir my-awesome-package && cd my-awesome-package
# Initialize with npm
npm init -y
# Or use a more structured approach
npm init --scope=@yourusername
Recommended Structure
my-awesome-package/
├── src/ # Source code
│ ├── index.ts # Main entry point
│ └── utils.ts # Helper functions
├── dist/ # Compiled output (git-ignored)
│ ├── index.js # CommonJS build
│ ├── index.mjs # ESM build
│ └── index.d.ts # TypeScript declarations
├── tests/
│ └── index.test.ts # Tests
├── package.json
├── tsconfig.json # TypeScript config
├── .npmignore # Files to exclude from the published package
├── LICENSE
├── README.md
└── CHANGELOG.md
package.json — Every Field Explained
{
"name": "my-awesome-package",
"version": "1.0.0",
"description": "A clear, concise description of what this does",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"test": "vitest",
"prepublishOnly": "npm run build && npm test"
},
"keywords": ["utility", "helper", "awesome"],
"author": "Your Name <you@email.com>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/you/my-awesome-package"
},
"engines": {
"node": ">=18"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0"
}
}
The Important Fields
| Field | What It Does |
|---|---|
name |
Package name on npm (must be unique globally, or scoped @user/pkg) |
version |
Current version (SemVer format) |
main |
Entry point for require() (CommonJS) |
module |
Entry point for import (ESM) |
types |
TypeScript type declarations |
exports |
Modern way to define all entry points (takes priority over main/module) |
files |
What to include in the published package (whitelist approach) |
engines |
Minimum Node.js version required |
prepublishOnly |
Script that runs before npm publish — build + test |
Pro tip: The
filesfield is critical. It's a whitelist — only files/folders listed here get published. This keeps your package small and avoids leaking source, tests, or configs.
Building Your First Package
Let's build a practical utility package: a string transformation library.
src/index.ts
/**
* Converts a string to kebab-case
* "Hello World" → "hello-world"
*/
export function toKebabCase(str: string): string {
return str
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase();
}
/**
* Converts a string to camelCase
* "hello-world" → "helloWorld"
*/
export function toCamelCase(str: string): string {
return str
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
.replace(/^[A-Z]/, (char) => char.toLowerCase());
}
/**
* Converts a string to PascalCase
* "hello-world" → "HelloWorld"
*/
export function toPascalCase(str: string): string {
const camel = toCamelCase(str);
return camel.charAt(0).toUpperCase() + camel.slice(1);
}
/**
* Truncates a string with ellipsis
* truncate("Hello World", 8) → "Hello..."
*/
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + '...';
}
/**
* Slugifies a string (URL-safe)
* "Hello World! 123" → "hello-world-123"
*/
export function slugify(str: string): string {
return str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
Build with tsup
tsup is the easiest way to build npm packages — it bundles TypeScript into CJS + ESM with type declarations in one command.
npm install -D tsup typescript
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"strict": true,
"outDir": "./dist"
},
"include": ["src"]
}
# Build
npx tsup src/index.ts --format cjs,esm --dts
# Output:
# dist/index.js (CommonJS)
# dist/index.mjs (ESM)
# dist/index.d.ts (TypeScript types)
Supporting ESM and CommonJS
The JavaScript ecosystem is split between CommonJS (require) and ESM (import). Your package should support both.
The exports field handles this:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.mjs",
"require": "./dist/utils.js"
}
}
}
Now users can do:
// ESM
import { slugify } from 'my-awesome-package';
// CommonJS
const { slugify } = require('my-awesome-package');
// Sub-path import
import { helper } from 'my-awesome-package/utils';
Adding TypeScript Support
TypeScript types are not optional in 2026. Packages without types lose downloads.
Option 1: Write in TypeScript + Generate declarations (recommended)
# tsup generates .d.ts files automatically
npx tsup src/index.ts --format cjs,esm --dts
Option 2: Write in JS + Add types separately
// index.d.ts — manual type declarations
export declare function slugify(str: string): string;
export declare function toKebabCase(str: string): string;
export declare function truncate(str: string, maxLength: number): string;
Then in package.json: "types": "./index.d.ts"
Testing Your Package
npm install -D vitest
tests/index.test.ts
import { describe, it, expect } from 'vitest';
import { toKebabCase, toCamelCase, slugify, truncate } from '../src/index';
describe('toKebabCase', () => {
it('converts spaces', () => {
expect(toKebabCase('Hello World')).toBe('hello-world');
});
it('converts camelCase', () => {
expect(toKebabCase('helloWorld')).toBe('hello-world');
});
it('converts PascalCase', () => {
expect(toKebabCase('HelloWorld')).toBe('hello-world');
});
});
describe('slugify', () => {
it('handles special characters', () => {
expect(slugify('Hello World! @#$')).toBe('hello-world');
});
it('trims dashes', () => {
expect(slugify(' --hello-- ')).toBe('hello');
});
});
describe('truncate', () => {
it('truncates long strings', () => {
expect(truncate('Hello World', 8)).toBe('Hello...');
});
it('returns short strings as-is', () => {
expect(truncate('Hi', 10)).toBe('Hi');
});
});
npx vitest run # Run once
npx vitest # Watch mode
Test Locally Before Publishing
# Creates a symlink to use your package locally
npm link
# In another project
npm link my-awesome-package
# Or use npm pack to test the actual published output
npm pack --dry-run # See what will be included
npm pack # Creates my-awesome-package-1.0.0.tgz
Publishing to NPM
First Time Setup
# Create an npm account (or login)
npm adduser
# Or login if you have an account
npm login
# Verify you're logged in
npm whoami
Publish
# Dry run first (see what will be published)
npm publish --dry-run
# Publish for real
npm publish
# For scoped packages (public)
npm publish --access public
What Happens on Publish
npm publish
│
├── Runs "prepublishOnly" script (build + test)
├── Creates tarball from files in "files" field
├── Uploads to registry.npmjs.org
└── Package is now live!
.npmignore (or use files field)
# .npmignore — exclude from published package
src/
tests/
.github/
tsconfig.json
*.test.ts
.env
node_modules/
Better approach: Use the
"files"field in package.json instead of.npmignore. It's a whitelist, which is safer:"files": ["dist", "README.md"]
Versioning — SemVer Done Right
NPM uses Semantic Versioning (SemVer): MAJOR.MINOR.PATCH
1 . 4 . 2
│ │ │
│ │ └── PATCH: Bug fixes, no API changes
│ └────────── MINOR: New features, backwards compatible
└────────────────── MAJOR: Breaking changes
Real Examples
| Change | Version Bump | Example |
|---|---|---|
| Fix a typo in output | PATCH: 1.0.0 → 1.0.1 | Bug fix |
| Add a new function | MINOR: 1.0.1 → 1.1.0 | New feature |
| Rename a function | MAJOR: 1.1.0 → 2.0.0 | Breaking change |
| Remove a function | MAJOR: 2.0.0 → 3.0.0 | Breaking change |
Version Ranges in package.json
{
"dependencies": {
"exact": "1.2.3",
"patch-updates": "~1.2.3",
"minor-updates": "^1.2.3",
"any": "*"
}
}
| Symbol | Meaning | Range for ^1.2.3
|
|---|---|---|
^ |
Compatible with version | >=1.2.3 <2.0.0 |
~ |
Approximately equivalent | >=1.2.3 <1.3.0 |
| None | Exact version | Exactly 1.2.3
|
Bumping Versions
npm version patch # 1.0.0 → 1.0.1
npm version minor # 1.0.1 → 1.1.0
npm version major # 1.1.0 → 2.0.0
# Then publish
npm publish
Monorepos & Workspaces
For multiple related packages, use npm workspaces:
my-toolkit/
├── package.json # Root
├── packages/
│ ├── core/
│ │ ├── package.json # @toolkit/core
│ │ └── src/
│ ├── cli/
│ │ ├── package.json # @toolkit/cli
│ │ └── src/
│ └── utils/
│ ├── package.json # @toolkit/utils
│ └── src/
// Root package.json
{
"name": "my-toolkit",
"private": true,
"workspaces": ["packages/*"]
}
# Install deps for all packages
npm install
# Run a script in a specific package
npm run build -w packages/core
# Run a script in ALL packages
npm run build --workspaces
# Publish all packages
npm publish --workspaces --access public
Scoped & Private Packages
Scoped Packages (@scope/package)
{ "name": "@ishaanpandey/string-utils" }
# Publish scoped package (public)
npm publish --access public
# Install
npm install @ishaanpandey/string-utils
Private Packages
For company-internal packages, use:
- npm Teams/Organizations ($7/user/month) — private packages on npm registry
- GitHub Packages — Free private packages tied to your GitHub repos
- Verdaccio — Self-hosted npm registry (free, open source)
Automating Releases with CI/CD
GitHub Actions: Auto-publish on version tag
# .github/workflows/publish.yml
name: Publish to NPM
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm test
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# To publish: tag and push
npm version patch
git push && git push --tags
# → GitHub Actions builds, tests, and publishes automatically
Making People Actually Use Your Package
- README is everything — Clear description, install command, usage examples, API docs
- TypeScript types — No types = lost users in 2026
-
Small bundle size — Check with
npm pack --dry-runand bundlephobia.com - Zero/few dependencies — Every dependency is a risk (supply chain, bloat)
- Good naming — Descriptive, memorable, searchable
- Badges in README — npm version, downloads, CI status, bundle size
- Changelog — Users want to know what changed before upgrading
Common Mistakes
-
Publishing
node_modulesor source files — Use"files"in package.json - No TypeScript types — You'll lose half your potential users
- Breaking changes in a patch release — Follow SemVer strictly
-
Not testing the published output — Always
npm pack --dry-runbefore publishing -
Forgetting
prepublishOnly— Publish raw TypeScript that doesn't work -
Huge bundle for a small utility — Don't import all of
lodashas a dependency for one function -
Not setting
engines— Your package might break on older Node versions silently
Let's Connect!
If you found this guide helpful, I'd love to connect with you! I regularly share deep dives on JavaScript, Node.js, and developer tooling.
Connect with me on LinkedIn — let's grow together.
Share this with someone who's about to build their first npm package!
Top comments (0)