Creating Your Own Package

Difficulty: Intermediate

Creating and publishing your own npm package is a fundamental skill for any Node.js developer. Whether you are sharing a utility library with the open-source community or creating private packages for your organization, understanding package structure and the publishing workflow is essential. A well-structured package is easy to consume, maintain, and distribute.

The package.json file is the heart of every npm package. Beyond the basic name and version fields, several fields control how your package is discovered and consumed. The 'main' field specifies the entry point for CommonJS require calls (default: index.js). The 'module' field (unofficial but widely supported) points to an ES Module entry point for bundlers. The 'exports' field (Node.js 12+) is the modern way to define entry points and replaces both 'main' and 'module' with a more powerful conditional exports map.

The 'exports' field lets you define different entry points for different environments. You can specify separate paths for CommonJS (require) and ES Module (import) consumers: '"exports": { ".": { "require": "./dist/index.cjs", "import": "./dist/index.mjs" } }'. You can also define subpath exports to expose specific files: '"exports": { "./utils": "./src/utils.js" }' allows consumers to write 'import { helper } from "your-package/utils"'. Anything not explicitly listed in exports is inaccessible to consumers, giving you control over your public API.

The 'files' field is an array of file patterns that specifies which files to include when your package is published. By default, npm includes everything except what is listed in .npmignore (or .gitignore if .npmignore does not exist). Setting 'files' to ["dist"] ensures only your compiled output is published, not source files, tests, or configuration. Use 'npm pack' to create a tarball and inspect what would be published before actually publishing. The 'npm pack --dry-run' command lists the files without creating the archive.

To publish a package, you need an npm account (create one at npmjs.com). Run 'npm login' to authenticate, then 'npm publish' to publish. Package names must be unique on the registry. If your desired name is taken, you can use a scoped package: '@yourname/package-name'. Scoped packages are namespaced under your account or organization and are private by default on npm. To publish a scoped package publicly, use 'npm publish --access public'. Always use semantic versioning and update the version with 'npm version patch/minor/major' before publishing.

Code examples

Complete package.json for a Library

const packageJson = {
  "name": "@myorg/string-utils",
  "version": "1.2.0",
  "description": "A collection of string utility functions",
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "require": "./dist/index.cjs",
      "import": "./dist/index.mjs",
      "types": "./dist/index.d.ts"
    },
    "./capitalize": {
      "require": "./dist/capitalize.cjs",
      "import": "./dist/capitalize.mjs"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "prepublishOnly": "npm run build"
  },
  "keywords": ["string", "utils", "capitalize"],
  "author": "Your Name",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/myorg/string-utils"
  }
};

console.log(JSON.stringify(packageJson, null, 2));

This package.json supports both CJS and ESM consumers via the exports field. The 'files' field ensures only the dist directory is published. The 'prepublishOnly' script automatically builds before publishing. Subpath exports let consumers import specific modules.

Creating the Package Source

// --- src/index.ts ---
export { capitalize } from './capitalize';
export { slugify } from './slugify';

// --- src/capitalize.ts ---
export function capitalize(str) {
  if (!str) return '';
  return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}

// --- src/slugify.ts ---
export function slugify(str) {
  return str
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9\s-]/g, '')
    .replace(/[\s_]+/g, '-')
    .replace(/-+/g, '-');
}

// Test the functions
const { capitalize } = { capitalize: (s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() };
const { slugify } = { slugify: (s) => s.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/[\s_]+/g, '-') };

console.log(capitalize('hello world'));
console.log(slugify('Hello World! This is Great'));

A barrel file (index.ts) re-exports from individual modules, giving consumers a clean import API. Each function is in its own file for tree-shaking. The package can be consumed as 'import { capitalize } from "@myorg/string-utils"'.

Publishing Workflow

// Terminal commands for publishing:

// 1. Login to npm
// npm login

// 2. Check what will be published
// npm pack --dry-run

// 3. Bump version (updates package.json and creates git tag)
// npm version patch   -> 1.2.0 -> 1.2.1 (bug fixes)
// npm version minor   -> 1.2.0 -> 1.3.0 (new features)
// npm version major   -> 1.2.0 -> 2.0.0 (breaking changes)

// 4. Publish
// npm publish                      (unscoped, public)
// npm publish --access public      (scoped, public)

// 5. Verify
// npm info @myorg/string-utils

console.log('Publishing checklist:');
console.log('1. Run tests: npm test');
console.log('2. Build: npm run build');
console.log('3. Dry run: npm pack --dry-run');
console.log('4. Bump version: npm version <patch|minor|major>');
console.log('5. Publish: npm publish --access public');

Always test and build before publishing. npm pack --dry-run lets you verify which files will be included. npm version automatically updates package.json and creates a git tag. For scoped packages, --access public is required for the first publish.

Key points

Concepts covered

package.json Anatomy, main Field, exports Field, files Field, Publishing, Scoped Packages