How to Create and Publish an npm Package
You’ve installed hundreds of packages with npm install. Now you want to publish your own. The process seems straightforward until you discover outdated tutorials recommending Node 12, CommonJS-only setups, and long-lived tokens stored in CI secrets.
This guide walks you through creating and publishing an npm package using modern, secure practices that will still be correct in 2025. We’ll build a simple utility, configure it properly, and publish it with npm Trusted Publishing.
Key Takeaways
- Use ESM with proper
exportsfield instead of the legacymainfield for modern package resolution - Compile TypeScript to JavaScript with declaration files rather than shipping raw TypeScript
- Enable two-factor authentication and use npm Trusted Publishing via GitHub Actions OIDC for secure automated releases
- Always verify your package contents with
npm pack --dry-runbefore publishing
Prerequisites
Before starting, ensure you have:
- Node.js 18 or later installed
- A GitHub account
- Basic familiarity with npm as a consumer
Initialize Your ESM-First npm Package Setup
Create a new directory and initialize your project:
mkdir use-toggle && cd use-toggle
npm init -y
git init
We’ll build a simple React hook called useToggle. This example demonstrates TypeScript npm package publishing with proper entry points.
Configure package.json for Modern Publishing
Replace your package.json with a properly configured version:
{
"name": "@yourusername/use-toggle",
"version": "0.1.0",
"description": "A simple React hook for boolean toggle state",
"keywords": ["react", "hook", "toggle", "state"],
"license": "MIT",
"author": "Your Name",
"repository": {
"type": "git",
"url": "git+https://github.com/yourusername/use-toggle.git"
},
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"typescript": "^5.4.0",
"react": "^18.0.0",
"@types/react": "^18.0.0"
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
}
}
Key points for this ESM-first npm package setup:
type: "module"declares ESM as defaultexportsreplaces the legacymainfield—this is how modern Node resolves entry pointsfilescontrols what gets published (onlydist/)enginesspecifies Node 18+, avoiding legacy compatibility issues
Set Up TypeScript Compilation
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}
Create src/index.ts:
import { useState, useCallback } from 'react';
export function useToggle(initial = false): [boolean, () => void] {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle];
}
Run npm run build to compile. You’ll get dist/index.js and dist/index.d.ts—JavaScript plus type declarations, not raw TypeScript.
Secure npm Package Publishing in 2025
Create Your npm Account with 2FA
- Register at npmjs.com
- Enable two-factor authentication immediately (prefer WebAuthn/passkeys)
- All new packages now have 2FA enforcement enabled by default
- With the new session-based login model, publishing any package—new or existing—requires 2FA during the session
Your First Manual Publish
npm login now creates a two-hour session token. These tokens:
- Do not appear in the npm UI
- Expire automatically after two hours
- Cannot be reused in CI/CD
- Always enforce 2FA before publishing
Older tooling that authenticates via the deprecated CouchDB endpoint also receives two-hour session tokens during the transition period.
For your initial release:
npm login
npm publish --access public
The --access public flag is required for scoped packages on free accounts.
Verify your package at https://www.npmjs.com/package/@yourusername/use-toggle.
Discover how at OpenReplay.com.
npm Trusted Publishing with CI
Old tutorials tell you to create an NPM_TOKEN and store it as a repository secret. This approach is now obsolete: classic tokens have been permanently revoked, and long-lived CI tokens are no longer supported.
The modern alternative is npm Trusted Publishing via GitHub Actions OIDC. GitHub proves its identity to npm, and npm issues short-lived credentials automatically—no stored token required.
Configure Trusted Publishing
- On npmjs.com, go to your package → Settings → Publishing access
- Add a new “Trusted Publisher” with your GitHub repository details
Create the Workflow
Add .github/workflows/publish.yml:
name: Publish
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
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 publish --provenance --access public
The --provenance flag adds cryptographic attestation linking your package to its source repository—a supply chain security feature npm introduced in 2023.
If OIDC cannot be used (for example, self-hosted runners), create a short-lived granular access token using:
npm token create
Granular tokens can opt into Bypass 2FA for CI publishing and must expire within 90 days.
What You’ll See in Old Tutorials
Avoid these outdated patterns:
"main": "index.js"withoutexports—useexportsfor proper ESM resolution- Targeting Node 10/12/14—Node 18+ is supported, but new packages should target modern LTS versions (Node 20 or 22)
- CommonJS-only packages—ESM is the standard; ship ESM-first
- Permanent
NPM_TOKENin every workflow—classic tokens are fully revoked; use tokenless Trusted Publishing
Verify Before Publishing
Always test your package locally before publishing:
npm pack --dry-run
This shows exactly what files will be included. If you see src/ or node_modules/, your files field needs adjustment.
Conclusion
To create and publish an npm package in 2025: use ESM with proper exports, compile TypeScript to JavaScript with declarations, enable 2FA, and automate releases with npm Trusted Publishing. Skip the legacy patterns—they’ll cause headaches for you and your users.
FAQs
Publish compiled JavaScript with declaration files. Ship the dist folder containing your compiled JS and generated .d.ts files. This ensures compatibility across different TypeScript versions and build tools. Raw TypeScript forces consumers to compile your code, which can cause version conflicts and slower builds.
Use dependencies for packages your code bundles and ships. Use peerDependencies for packages consumers must provide, like React for a React hook. This prevents duplicate copies of large libraries in the final bundle. Your package expects the consumer's version rather than bundling its own.
Scoped packages like @username/package-name default to private on npm. Free npm accounts cannot publish private packages, so you must explicitly set --access public. This flag tells npm to publish the package publicly. You only need it for the first publish; subsequent versions inherit the access level.
Use npm deprecate @scope/package to warn users without removing the package. Full unpublishing with npm unpublish is restricted to packages published within 72 hours or those with minimal downloads. For abandoned packages, deprecation is preferred as it warns users while preserving existing installations.
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.