To make an npm package you initialize a project with npm init, write your module and its package.json metadata, decide exactly which files to publish, and run npm publish to push it to the registry. That is the whole arc, and you can do it in ten minutes. The reason this guide runs longer than ten minutes is that the difference between a package and a safe package is entirely in the details people skip: which files get bundled, what runs during install, and whether your secrets ride along for the trip.
If you have searched for how to make a npm package and found a two-command answer, it was not wrong, just incomplete in the ways that matter once real users depend on you.
Step 1: Initialize the project
Create a directory, initialize git, and scaffold the manifest.
mkdir string-slug && cd string-slug
git init
npm init -y
npm init -y writes a default package.json. Open it and set the fields that actually matter: a unique name, a starting version (follow semantic versioning, so 1.0.0 for a first stable release or 0.1.0 if you are still moving fast), a description, and a license. If the name you want is taken, use a scope, which also namespaces the package under your account:
npm init --scope=@yourname -y # produces @yourname/string-slug
Step 2: Write the module and define its entry points
Keep the source in a src/ folder and the published output somewhere predictable like dist/. The package.json fields tell the registry and bundlers how to load your code. For a modern package supporting both ESM and CommonJS, the exports map is the source of truth:
{
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
If you ship TypeScript types, the types field and the types condition inside exports are what make your package usable from typed projects without a separate @types install.
Step 3: Control exactly what gets published
This is the step that separates careful packages from embarrassing ones. By default, npm publishes almost everything in the directory that is not git-ignored, which is how people accidentally ship test fixtures, source maps, and .env files full of credentials.
Use the files allowlist in package.json. An allowlist is safer than a .npmignore denylist, because forgetting to add a file means it is excluded, not leaked:
{
"files": ["dist", "README.md", "LICENSE"]
}
Then verify before you publish. npm pack produces the exact tarball that would be uploaded, and --dry-run lists its contents without creating a file:
npm publish --dry-run
Read that file list carefully. If you see anything with secret, .env, .key, or an internal path in the name, stop and fix your files field before going further.
Step 4: Test and build
Add a build step and a test command so the published artifact is the compiled one, not your raw source. The prepublishOnly script runs automatically right before publish, which is the ideal place to gate on tests and a build:
{
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts",
"test": "vitest run",
"prepublishOnly": "npm run test && npm run build"
}
}
Now a publish cannot succeed with failing tests, which is a small guardrail that saves real embarrassment.
Step 5: Publish safely
Publishing needs an authenticated npm account. Two security practices are non-negotiable here.
First, enable two-factor authentication on your npm account and require it for publishes. Account takeover of a maintainer is a primary way malicious versions of popular packages get pushed.
npm login
npm publish --access public # required for scoped packages to be public
Second, be deliberate about lifecycle scripts. A postinstall script runs on every machine that installs your package, which makes it a favorite vector for supply-chain attacks. If your package genuinely needs one, document why. If it does not, do not add one. When you consume other packages, the same risk applies in reverse, and an SCA tool can flag dependencies that run install-time scripts so a malicious postinstall three levels deep does not run silently on your CI.
Step 6: Version and maintain responsibly
Once people depend on you, versioning is a contract. Bump the patch version for fixes, the minor for backward-compatible features, and the major for breaking changes. npm version bumps the manifest and creates a git tag in one step:
npm version patch # 1.0.0 -> 1.0.1, and tags the commit
git push --follow-tags
npm publish
If you publish a broken version, do not delete it, because unpublishing breaks everyone who already pinned it. Publish a fixed version and, if needed, deprecate the bad one with npm deprecate. Keeping dependencies current is part of maintenance too; the Academy has walkthroughs on running a healthy release process.
FAQ
How do I make an npm package in the simplest possible way?
Run npm init -y to create a package.json, write your module with a clear entry point, set a files allowlist so only intended files publish, then run npm publish. Always run npm publish --dry-run first to confirm the tarball contents.
How do I stop secrets from being published in my npm package?
Use the files allowlist in package.json rather than relying on .npmignore, so anything you forget to list is excluded by default. Then run npm publish --dry-run and inspect the file list for anything sensitive before publishing.
What is the difference between a scoped and unscoped npm package?
An unscoped package has a bare name like string-slug and must be globally unique. A scoped package like @yourname/string-slug is namespaced under your account or org, avoids name collisions, and needs --access public to be published publicly.
Are postinstall scripts in npm packages dangerous?
They can be. A postinstall script runs automatically on every install, which makes it a common supply-chain attack vector. Only include one if genuinely necessary, and when consuming packages, scan dependencies for install-time scripts so a malicious one does not execute unnoticed.