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
// 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)
}
}
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 };
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...');
});
});
Step 4: Build Setup
I use tsup — zero-config build tool for TypeScript:
npm install -D tsup
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
sourcemap: true,
clean: true,
});
One command builds everything:
dist/
├── index.js # CommonJS
├── index.mjs # ESM
├── index.d.ts # Type definitions
└── index.js.map # Source maps
Step 5: README.md (Your Landing Page)
# My Awesome Package
[](https://www.npmjs.com/package/@armorbreak/my-awesome-package)
[](https://opensource.org/licenses/MIT)
[](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)
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
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
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
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
What I Learned the Hard Way
-
Scope your package.
@username/package-nameavoids naming conflicts. -
Use
prepublishOnly. Ensures you never publish untested/unbuilt code. - Test on multiple Node versions. GitHub Actions matrix is free.
- Write good README. It's the first thing people see. Include examples.
- Version carefully. Semver exists for a reason. Breaking changes = major version bump.
-
Don't publish secrets. Triple-check
.npmignore. -
Use
--dry-runfirst. 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)