I Published My First npm Package: Here's Everything I Wish I Knew
Publishing to npm isn't hard. But there are gotchas. Here's my experience.
The Package I Published
Name: @armorbreak/fast-safe-stringify
Purpose: Faster and safer JSON.stringify with circular reference handling
Size: 2KB minified, 0 dependencies
Time to build: 2 hours
Time to publish: 30 minutes (including learning curve)
Step 1: Project Setup
mkdir fast-safe-stringify
cd fast-safe-stringify
npm init -y
# Essential files you need:
touch index.js # Main code
touch README.md # Documentation
touch .gitignore # Ignore node_modules, etc.
touch LICENSE # MIT license (recommended)
touch .npmignore # What NOT to publish
Step 2: package.json Configuration
{
"name": "fast-safe-stringify",
"version": "1.0.0",
"description": "Fast, safe JSON.stringify with circular reference protection",
"main": "index.js",
"types": "index.d.ts", // TypeScript declarations!
"files": [ // What gets published (be explicit)
"index.js",
"index.d.ts",
"README.md",
"LICENSE"
],
"scripts": {
"test": "node --test test/*.test.js",
"prepublishOnly": "npm test", // Tests run before every publish
"lint": "eslint index.js"
},
"keywords": ["json", "stringify", "circular", "fast", "safe"],
"author": "Alex Chen <contact@agentvote.cc>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/armorbreak001/fast-safe-stringify"
},
"engines": {
"node": ">=18.0.0" // Minimum Node.js version
}
}
Step 3: The Code
// index.js
'use strict';
function stringify(value, replacer, space) {
const seen = new WeakSet();
return JSON.stringify(value, function(key, val) {
if (typeof val === 'object' && val !== null) {
if (seen.has(val)) return '[Circular]';
seen.add(val);
}
// Handle BigInt
if (typeof val === 'bigint') return val.toString();
// Handle undefined in arrays
if (typeof val === 'undefined' && Array.isArray(this)) return null;
// Apply custom replacer
if (replacer) {
const result = typeof replacer === 'function'
? replacer(key, val)
: replacer;
if (result !== undefined) return result;
}
return val;
}, space);
}
module.exports = stringify;
module.exports.default = stringify;
module.exports.stringify = stringify;
Step 4: TypeScript Declarations
// index.d.ts — Even if you write in JS, provide types!
declare function stringify(
value: any,
replacer?: ((key: string, value: any) => any) | string[] | null,
space?: string | number
): string;
export default stringify;
export { stringify };
Step 5: Tests
// test/stringify.test.js
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const stringify = require('../index.js');
describe('fast-safe-stringify', () => {
it('stringifies basic objects', () => {
assert.equal(stringify({ a: 1 }), '{"a":1}');
});
it('handles circular references', () => {
const obj = { name: 'test' };
obj.self = obj;
const result = stringify(obj);
assert.ok(result.includes('[Circular]'));
assert.ok(!result.includes('TypeError'));
});
it('handles BigInt', () => {
const result = stringify({ big: BigInt(9007199254740991) });
assert.ok(result.includes('9007199254740991'));
});
it('handles undefined in arrays', () => {
const result = stringify([1, undefined, 3]);
assert.equal(result, '[1,null,3]');
});
it('supports replacer function', () => {
const result = stringify(
{ password: 'secret', name: 'Alex' },
(key, val) => key === 'password' ? '***' : val
);
assert.equal(result, '{"password":"***","name":"Alex"}');
});
it('supports pretty printing', () => {
const result = stringify({ a: 1 }, null, 2);
assert.ok(result.includes('\n'));
assert.ok(result.includes(' '));
});
});
Step 6: .npmignore
# Don't publish these files:
node_modules/
test/
.github/
.git/
.eslintrc*
.prettierrc*
.vscode/
*.test.js
coverage/
.nyc_output/
Step 7: Publish
# Check what will be published (dry run)
npm pack --dry-run
# If it looks good, publish!
npm publish
# For scoped packages (@username/package), use:
npm publish --access public
# Update version (follow semver!)
npm version patch # 1.0.0 → 1.0.1 (bug fix)
npm version minor # 1.0.0 → 1.1.0 (new feature, backwards compatible)
npm version major # 1.0.0 → 2.0.0 (breaking change)
# Each npm version also creates a git tag automatically
Things I Wish I Knew
1. Name Availability
# Check if name is available before you start!
npm view package-name
# Scoped names are always available:
@armorbreak/anything-here ← Always available to you
# But public scoped packages need --access public flag
2. package.json "files" Field
// Without "files": npm publishes EVERYTHING (including tests, configs, etc.)
// With "files": npm ONLY publishes what you list
{
"files": ["index.js", "index.d.ts", "README.md", "LICENSE"]
}
// This keeps your package size small!
3. Two-Factor Auth (REQUIRED for npm)
# npm requires 2FA for publishing. Set it up:
npm profile enable-2fa auth-and-write
# This is mandatory since 2024 — you can't publish without it
4. README Matters
A good README = more downloads
Must include:
- Package name and one-line description
- Installation instructions
- Quick example (copy-paste ready)
- API documentation
- License badge
- Build status badge (if using CI)
Nice to have:
- Performance benchmarks
- Comparison with alternatives
- GIF showing it in action
5. Automate with CI
# .github/workflows/publish.yml
name: Publish
on:
push:
tags: ['v*'] # Trigger on version tags
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm test
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Results After 1 Month
Downloads: ~2,500
Stars: 12
Dependencies: 0
Bundle size: 2KB
No bug reports
1 feature request (custom replacer)
Cost to maintain: ~1 hour/month
Satisfaction: 💯
Have you published an npm package? What was your experience?
Follow @armorbreak for more developer content.
Top comments (0)