DEV Community

Ishaan Pandey
Ishaan Pandey

Posted on • Originally published at ishaaan.hashnode.dev

NPM Packages 101: The Ultimate Guide to Building, Publishing & Managing Your Own Packages

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

  1. Why Build NPM Packages?
  2. How NPM Works Under the Hood
  3. Project Setup — The Right Way
  4. package.json — Every Field Explained
  5. Building Your First Package
  6. Supporting ESM and CommonJS
  7. Adding TypeScript Support
  8. Testing Your Package
  9. Publishing to NPM
  10. Versioning — SemVer Done Right
  11. Monorepos & Workspaces
  12. Scoped & Private Packages
  13. Automating Releases with CI/CD
  14. Making People Actually Use Your Package
  15. 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
Enter fullscreen mode Exit fullscreen mode
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│
                              │       └── ...    │
                              └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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 files field 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, '');
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "declaration": true,
    "strict": true,
    "outDir": "./dist"
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode
# 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)
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Then in package.json: "types": "./index.d.ts"


Testing Your Package

npm install -D vitest
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode
npx vitest run  # Run once
npx vitest      # Watch mode
Enter fullscreen mode Exit fullscreen 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

.npmignore (or use files field)

# .npmignore — exclude from published package
src/
tests/
.github/
tsconfig.json
*.test.ts
.env
node_modules/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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": "*"
  }
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode
// Root package.json
{
  "name": "my-toolkit",
  "private": true,
  "workspaces": ["packages/*"]
}
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

Scoped & Private Packages

Scoped Packages (@scope/package)

{ "name": "@ishaanpandey/string-utils" }
Enter fullscreen mode Exit fullscreen mode
# Publish scoped package (public)
npm publish --access public

# Install
npm install @ishaanpandey/string-utils
Enter fullscreen mode Exit fullscreen mode

Private Packages

For company-internal packages, use:

  1. npm Teams/Organizations ($7/user/month) — private packages on npm registry
  2. GitHub Packages — Free private packages tied to your GitHub repos
  3. 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 }}
Enter fullscreen mode Exit fullscreen mode
# To publish: tag and push
npm version patch
git push && git push --tags
# → GitHub Actions builds, tests, and publishes automatically
Enter fullscreen mode Exit fullscreen mode

Making People Actually Use Your Package

  1. README is everything — Clear description, install command, usage examples, API docs
  2. TypeScript types — No types = lost users in 2026
  3. Small bundle size — Check with npm pack --dry-run and bundlephobia.com
  4. Zero/few dependencies — Every dependency is a risk (supply chain, bloat)
  5. Good naming — Descriptive, memorable, searchable
  6. Badges in README — npm version, downloads, CI status, bundle size
  7. Changelog — Users want to know what changed before upgrading

Common Mistakes

  1. Publishing node_modules or source files — Use "files" in package.json
  2. No TypeScript types — You'll lose half your potential users
  3. Breaking changes in a patch release — Follow SemVer strictly
  4. Not testing the published output — Always npm pack --dry-run before publishing
  5. Forgetting prepublishOnly — Publish raw TypeScript that doesn't work
  6. Huge bundle for a small utility — Don't import all of lodash as a dependency for one function
  7. 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)