DEV Community

Alex Chen
Alex Chen

Posted on

I Published My First npm Package — Here's Everything I Wish I Knew

I Published My First npm Package — Here's Everything I Wish I Knew

Publishing to npm is easy. Doing it RIGHT takes some planning. Here's my complete checklist.

Before You Start

Is It Worth Publishing?

Don't publish if:

  • It's specific to one project
  • The name is already taken (obviously)
  • You're not willing to maintain it

Do publish if:

  • You've copied this code between 3+ projects
  • Others would benefit from it
  • You're willing to fix bugs and accept PRs

Step 1: Project Setup

mkdir my-awesome-package && cd my-awesome-package
npm init -y

# Install dev dependencies
npm install -D typescript @types/node jest ts-jest

# Create source structure
mkdir src tests
Enter fullscreen mode Exit fullscreen mode
// package.json  the important fields
{
  "name": "@armorbreak/my-awesome-package",
  "version": "1.0.0",
  "description": "A brief description of what your package does",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist", "README.md", "LICENSE"],
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "test": "jest",
    "prepublishOnly": "npm run build && npm test"
  },
  "keywords": ["utility", "nodejs"],
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/armorbreak001/my-awesome-package.git"
  },
  "bugs": {
    "url": "https://github.com/armorbreak001/my-awesome-package/issues"
  },
  "homepage": "https://github.com/armorbreak001/my-awesome-package#readme",
  "engines": {
    "node": ">=16.0.0"
  },
  "publishConfig": {
    "access": "public" // Required for scoped packages (@scope/name)
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Fields Explained

Field Why It Matters
main Entry point for CommonJS (require())
types TypeScript type definitions
exports Modern way to define entry points (Node 12+)
files Controls what gets published (don't publish tests!)
engines Prevents installation on incompatible Node versions
publishConfig.access Required for @scoped packages

Step 2: Write the Code

// src/index.ts
export interface Options {
  prefix?: string;
  separator?: string;
  maxLength?: number;
}

const DEFAULTS: Required<Options> = {
  prefix: '',
  separator: ', ',
  maxLength: Infinity,
};

/**
 * Formats an array of items into a human-readable string.
 * 
 * @example
 * formatList(['a', 'b', 'c']) // => 'a, b, and c'
 */
export function formatList(items: string[], options?: Options): string {
  const opts = { ...DEFAULTS, ...options };

  if (items.length === 0) return '';
  if (items.length === 1) return opts.prefix + items[0];

  const formatted = items.map(item => item.trim()).filter(Boolean);

  if (formatted.length === 2) {
    return `${opts.prefix}${formatted[0]} and ${formatted[1]}`;
  }

  const last = formatted.pop();
  return `${opts.prefix}${formatted.join(opts.separator)}and ${last}`;
}

/**
 * Truncates text to a maximum length.
 */
export function truncate(text: string, maxLength: number, suffix = '...'): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength - suffix.length).trimEnd() + suffix;
}

// Re-export everything from index.ts
export { formatList, truncate };
Enter fullscreen mode Exit fullscreen mode

Step 3: Tests (Non-Negotiable)

// tests/index.test.ts
import { formatList, truncate } from '../src/index';

describe('formatList', () => {
  it('handles empty array', () => {
    expect(formatList([])).toBe('');
  });

  it('handles single item', () => {
    expect(formatList(['apple'])).toBe('apple');
  });

  it('handles two items', () => {
    expect(formatList(['apple', 'banana'])).toBe('apple and banana');
  });

  it('handles three+ items', () => {
    expect(formatList(['a', 'b', 'c'])).toBe('a, b, and c');
  });

  it('respects prefix option', () => {
    expect(formatList(['x', 'y'], { prefix: 'Items: ' })).toBe('Items: x and y');
  });
});

describe('truncate', () => {
  it('returns original if under limit', () => {
    expect(truncate('hello', 10)).toBe('hello');
  });

  it('truncates long text', () => {
    expect(truncate('Hello World!', 8)).toBe('Hello...');
  });
});
Enter fullscreen mode Exit fullscreen mode

Step 4: Build Setup

I use tsup — zero-config build tool for TypeScript:

npm install -D tsup
Enter fullscreen mode Exit fullscreen mode
// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['cjs', 'esm'],
  dts: true,
  sourcemap: true,
  clean: true,
});
Enter fullscreen mode Exit fullscreen mode

One command builds everything:

dist/
├── index.js       # CommonJS
├── index.mjs      # ESM
├── index.d.ts     # Type definitions
└── index.js.map   # Source maps
Enter fullscreen mode Exit fullscreen mode

Step 5: README.md (Your Landing Page)

# My Awesome Package

[![npm version](https://img.shields.io/npm/v/@armorbreak/my-awesome-package.svg)](https://www.npmjs.com/package/@armorbreak/my-awesome-package)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/)

> A brief tagline that explains what this does in one sentence.

## Installation

\`\`\`bash
npm install @armorbreak/my-awesome-package
\`\`\`

## Quick Start

\`\`\`typescript
import { formatList, truncate } from '@armorbreak/my-awesome-package';

console.log(formatList(['apple', 'banana', 'cherry']));
// => 'apple, banana, and cherry'

console.log(truncate('This is very long text', 10));
// => 'This is...'
\`\`\`

## API Reference

### \`formatList(items, options?)\`

Formats an array into a human-readable string.

| Parameter | Type | Default |
|-----------|------|---------|
| \`items\` | \`string[]\` | (required) |
| \`options.prefix\` | \`string\` | \`''\` |
| \`options.separator\` | \`string\` | \`', '\` |
| \`options.maxLength\` | \`number\` | \`Infinity\` |

### \`truncate(text, maxLength, suffix?)\`

Truncates text to fit within a character limit.

## Contributing

PRs welcome! Please read the [contributing guidelines](CONTRIBUTING.md).

## License

MIT © [Your Name](https://github.com/yourusername)
Enter fullscreen mode Exit fullscreen mode

Step 6: .npmignore & .gitignore

# .npmignore — controls what gets PUBLISHED to npm
# (NOT the same as .gitignore!)
src/
tests/
*.ts
!*.d.ts
tsup.config.ts
jest.config.ts
.github/
.vscode/
.DS_Store
*.log
.env
.env.*
coverage/

# .gitignore — controls what goes to git
node_modules/
dist/
coverage/
.DS_Store
.env*
*.log
Enter fullscreen mode Exit fullscreen mode

Common mistake: Forgetting .npmignore and accidentally publishing your test files and source code.

Step 7: Publish!

# Login first time only
npm login

# Dry run (tests everything without actually publishing)
npm publish --dry-run

# Actually publish!
npm publish

# For a specific version:
npm publish --tag beta  # npm install my-p@beta
Enter fullscreen mode Exit fullscreen mode

Version Management

# Use npm version (updates package.json + git tag)
npm version patch   # 1.0.0 → 1.0.1 (bug fixes)
npm version minor   # 1.0.0 → 1.1.0 (new features)
npm version major   # 1.0.0 → 2.0.0 (breaking changes)

# Each command: updates version, commits, creates git tag
# Then: npm publish
Enter fullscreen mode Exit fullscreen mode

After Publishing

Check Your Package Page

Visit: https://www.npmjs.com/package/@armorbreak/my-awesome-package

Set Up GitHub Actions for CI

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - run: npm ci
      - run: npm test
      - run: npm run build
Enter fullscreen mode Exit fullscreen mode

What I Learned the Hard Way

  1. Scope your package. @username/package-name avoids naming conflicts.
  2. Use prepublishOnly. Ensures you never publish untested/unbuilt code.
  3. Test on multiple Node versions. GitHub Actions matrix is free.
  4. Write good README. It's the first thing people see. Include examples.
  5. Version carefully. Semver exists for a reason. Breaking changes = major version bump.
  6. Don't publish secrets. Triple-check .npmignore.
  7. Use --dry-run first. Catches issues before they're public.

What was your first npm package? Drop it in the comments.

Follow @armorbreak for more developer guides.

Top comments (0)