How to Create an NPM Package

A quick guide for those planning to create their own NPM package.

How to Create an NPM Package

Publishing your first NPM package can seem more complicated than it actually is. Once you strip away all the ceremony, it's really just: a folder, a package.json, some code, and a couple of commands.

The part that usually trips people up isn't the publishing — it's testing the package before it goes public, inside a real project, without round-tripping through the NPM registry every time you change a line.

This post walks through building a small package from scratch and then linking it into a consumer project.

1. Setup the Package

Start by creating a new empty folder and initialize it:

mkdir my-first-package
cd my-first-package
npm init -y

This creates a bare-bones package.json file at the root of the folder. Let's make some changes to it though:

{
  "name": "my-first-package",
  "version": "0.1.0",
  "description": "A small utility that does one thing and one thing only.",
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "test": "vitest run",
    "prepublishOnly": "npm run build"
  },
  "license": "MIT"
}

A few fields deserve a second look:

  • name: must be unique on the registry, or scoped (@vendor/my-first-package) to avoid potential conflicts.

  • main / module / types: point at your built output, not your source. Consumers shouldn't need your build tooling to use your package.

  • files: an allowlist of what actually gets published. Without it, NPM publishes almost everything not covered by .gitignore, including test files, configs, and whatever else is lying around.

  • prepublishOnly: makes sure you can never accidentally publish stale or unbuilt code.

2. Write the Source

Keep the source in src/, and export things from a single entry point:

// src/index.ts
export function slugify(text: string): string {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/(^-|-$)/g, '')
}

One entry point per package (or a small, intentional set of them via exports maps) makes the public API easy to reason about — both for your users and for future you.

3. Test It Locally

This is the step people usually skip past. You don't want to run npm publish every time you tweak something just to see if it works in your other project. There are three practical ways to test a package locally, in increasing order of how well they mirror the real published experience.

Option A: npm link, the classic approach

npm link creates a symlink from the global node_modules (or, more precisely, directly between the two projects) so a consumer project resolves your package straight from your working directory.

In the package folder:

cd my-first-package
npm run build
npm link

Then, in the consumer project:

cd ../my-app
npm link my-first-package

Now my-app's node_modules/my-awesome-package is a symlink pointing back at your package folder. Rebuild the package, and the change shows up in my-app immediately (assuming your build produces the dist/ files your main/module fields expect).

To undo it:

cd my-app
npm unlink my-first-package
cd ../my-first-package
npm unlink

There is a catch to this option, though. Symlinks can cause subtle issues — most commonly, duplicate copies of dependencies like React resolving from two different node_modules trees, which breaks things like hooks or instanceof checks. It's also easy to forget you have a link active and wonder why npm install isn't picking up your published changes.

Option B: npm pack, closer to the real thing

npm pack builds the tarball that would be uploaded to the registry, without actually publishing it.

cd my-first-package
npm run build
npm pack

This produces my-first-package-0.1.0.tgz. Install it in your consumer project like any other dependency:

cd ../my-app
npm install ../my-first-package/my-first-package-0.1.0.tgz

This is the most honest way to testing your package short of actually publishing it. It respects your files field, .npmignore, and package.json exports exactly as a real thing would. It's a good sanity check right before you publish, even if you've been using npm link for day-to-day iteration. The downside is the extra step of rebuilding and repacking on every change.

Option C: Workspaces

If the package and its consumer both live in repos you control, workspaces avoid linking headaches by managing the relationship structurally:

// root package.json
{
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}

With my-first-package in packages/ and my-app in apps/, a single npm install at the root wires everything together correctly — no manual linking, no stray symlinks to forget about, and dependency deduplication handled for you. This is the approach most monorepo-based projects converge on once a package is more than a quick experiment.

4. Publish Your Package

Once everything is done and you're happy with the result, you can publish it with:

npm login
npm version patch # or minor/major
npm publish
npm publish --access public # only needed for scoped packages: @vendor/my-first-package

Bump the version first with npm version patch|minor|major, which also tags the commit — handy for tracing a bug back to the release that introduced it.

A Reasonable Default Workflow

For most day-to-day package development, this combination works well:

  1. npm link while actively developing, for instant feedback.

  2. npm pack + install the tarball right before publishing, as a final check that what actually gets published works as expected.

  3. Move to workspaces once you're maintaining the package alongside its main consumer long-term.

None of this is complicated once you've done it once — but knowing the tradeoffs between link, pack, and workspaces up front saves a fair number of "why does this work locally but not after publish" debugging sessions.