
How To Create an npm Package

I hope you enjoy reading this post. If you'd like to work together Contact Me
Author: Joe Czubiak | Creator of Barnacles | Serial Indie Maker
Introduction
npm packages are reusable modules that you can distribute and install in other projects. While the public npm registry is the most common distribution method, you can also host packages on GitHub, GitLab, private registries, or install them directly from Git repositories. Creating your own npm packages allows you to share code across projects, contribute to open source, or distribute internal tools within your organization.
In this tutorial, you'll learn how to create a TypeScript npm package from scratch using npm init, configure TypeScript compilation, and test your package locally before publishing. You'll explore two methods for local testing: the npm link command and the file-based approach with absolute paths. You'll also learn about alternative distribution methods, including how to install packages from private GitHub repositories. Finally, you'll understand the limitations when mixing package managers during local development.
By the end, you'll know how to initialize a package with the right configuration, set up TypeScript for type-safe development, test it thoroughly in other projects on your machine, distribute packages through different channels, and avoid common pitfalls when working with different package managers.
Prerequisites
To follow this tutorial, you will need:
- Node.js version 16 or higher installed on your local machine
- npm (which comes bundled with Node.js)
- Basic familiarity with TypeScript and ES modules
- A text editor or IDE for editing package files
Quick Start with an LLM Prompt
If you're using an AI coding assistant like Claude, Cursor, or GitHub Copilot, you can use the following prompt to generate the complete package structure described in this tutorial:
Create a new npm package called "my-utility-package" with TypeScript configured. Set up the following:
1. package.json with:
- name: "my-utility-package"
- version: "1.0.0"
- main pointing to dist/index.js
- types pointing to dist/index.d.ts
- build script that runs tsc
- prepublishOnly script that runs the build
2. tsconfig.json with:
- target: ES2020
- module: ES2020
- declaration: true for generating .d.ts files
- outDir: ./dist
- rootDir: ./src
- strict mode enabled
- moduleResolution: node
3. src/index.ts with two example functions:
- greet(name: string): string - returns a greeting message
- add(a: number, b: number): number - returns the sum
4. A .gitignore file that excludes node_modules and dist
Install TypeScript as a dev dependency and create the directory structure.
This prompt creates the same package structure covered in Steps 1-3. Continue reading to understand what each configuration option does and how to test your package locally.
Step 1 — Initializing Your Package with npm init
The first step in creating an npm package is initializing it with a package.json file. This file contains metadata about your package, including its name, version, entry point, and dependencies.
Create a new directory for your package and navigate into it:
mkdir my-utility-package
cd my-utility-package
Run the npm init command to start the interactive package initialization:
npm init
The command will prompt you with several questions to help create your package.json file. All of these values can easily be edited later in the file itself. Here's what each option means:
package name: The name of your package as it will appear on npm. This must be lowercase and can include hyphens or underscores. If you plan to publish a scoped package (like @username/package-name), you can include the scope here.
version: The initial version number following semantic versioning (major.minor.patch). The default is 1.0.0. For a new package, you can start with 0.1.0 or 1.0.0.
description: A brief description of what your package does. This appears in search results on npmjs.com and helps users understand your package's purpose.
entry point: The main file that will be loaded when someone imports your package. For TypeScript packages, this will be the compiled JavaScript file, typically dist/index.js. During initialization, you can accept the default index.js and update it later.
test command: The command to run your package's tests. Common values include jest, mocha, or npm run test. You can leave this blank and add it later.
git repository: The URL of your package's git repository. This creates a link from the npm page to your source code. Format: https://github.com/username/repository. You can leave it blank for now.
keywords: An array of keywords that help users find your package when searching npm. Separate keywords with spaces.
author: Your name or organization. Format: Your Name <email@example.com>.
license: The license under which you're distributing your package. Common choices include MIT, ISC, or Apache-2.0. The default is ISC.
After answering all prompts, npm creates a package.json file with your configuration. Review the file to verify the settings are correct.
Your package.json file will look similar to this:
{
"name": "my-utility-package",
"version": "1.0.0",
"description": "A utility package for common functions",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": ["utility", "helper", "tools"],
"author": "Your Name <your.email@example.com>",
"license": "ISC"
}
The file contains all the metadata you provided during initialization. You'll update the main field and add build scripts in the next step when you configure TypeScript.
Alternatively, you can skip the interactive prompts and use default values:
npm init -y
The -y flag (or --yes) accepts all default values without prompting. This creates a package.json file immediately, which you can then edit manually.
You can also set specific values directly from the command line:
npm init -y --scope=@myusername
The --scope flag creates a scoped package name, which is useful for grouping related packages under your username or organization.
Now that you have initialized your package, you're ready to set up TypeScript and add code.
Step 2 — Setting Up TypeScript
We're going to use TypeScript in this example. It is optional but highly recommended. Before writing your package code, you need to install and configure TypeScript.
Install TypeScript as a development dependency:
npm install --save-dev typescript
The --save-dev flag adds TypeScript to your devDependencies because it's only needed during development. The compiled JavaScript will be distributed, not the TypeScript source.
Create a TypeScript configuration file:
npx tsc --init
This creates a tsconfig.json file with default TypeScript compiler options. Open tsconfig.json and update it with the following configuration:
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Here's what these key options do:
The target option specifies which JavaScript version to compile to. ES2020 provides modern features while maintaining broad compatibility.
The module option set to ES2020 tells TypeScript to output ES modules using import and export syntax.
The declaration option generates .d.ts type definition files alongside your compiled JavaScript, allowing TypeScript users to get full type checking when using your package.
The outDir option specifies where compiled files will be placed. Using dist keeps your compiled code separate from source files.
The rootDir option tells TypeScript where your source files are located. This maintains your directory structure in the output.
The strict option enables all strict type-checking options, helping you catch potential bugs during development.
Next, update your package.json to configure the package for TypeScript:
{
"name": "my-utility-package",
"version": "1.0.0",
"description": "A utility package built with TypeScript",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
The main field points to the compiled JavaScript file in the dist directory. This is what gets loaded when someone imports your package.
The types field points to the TypeScript type definition file, enabling type checking for users of your package.
The build script runs the TypeScript compiler to generate the JavaScript output.
The prepublishOnly script automatically builds your package before publishing to npm, ensuring you never publish without compiling.
With TypeScript configured, you're ready to write your package code.
Step 3 — Creating Package Code
Now you can create the TypeScript source code for your package.
Create a src directory and an index.ts file inside it:
mkdir src
Create src/index.ts with the following code:
// src/index.ts
export function greet(name: string): string {
return `Hello, ${name}! Welcome to my utility package.`;
}
export function add(a: number, b: number): number {
return a + b;
}
This package exports two functions with TypeScript type annotations. The greet() function accepts a string parameter and returns a string. The add() function accepts two number parameters and returns a number. These type annotations provide type safety during development and help users of your package understand the expected inputs and outputs.
Build your package to compile the TypeScript to JavaScript:
npm run build
TypeScript compiles your code and creates a dist directory containing the JavaScript output and type definitions. You'll see two files:
dist/index.js contains the compiled JavaScript code that will be executed.
dist/index.d.ts contains the type definitions for TypeScript users.
You can verify the compilation by checking the contents of dist/index.js:
// dist/index.js
export function greet(name) {
return `Hello, ${name}! Welcome to my utility package.`;
}
export function add(a, b) {
return a + b;
}
The compiled output is clean JavaScript with ES module syntax, while the type information is preserved in the separate .d.ts file.
With your package code compiled, you can now test it in another project without publishing to npm.
Step 4 — Test Your Package Locally
A critical step in developing an npm package is testing it out locally. The point of all of this is to create something you can use in another project after all. Next you'll explore two options for doing so.
Testing Your Package with npm link
The npm link command creates a symbolic link (symlink) from your package to your global node_modules directory, allowing you to use it in other projects as if it were installed from npm.
First, navigate to your package directory and create a global link:
cd /path/to/my-utility-package
npm link
npm creates a symlink in your global node_modules directory that points to your package folder. You'll see output confirming the link was created.
Next, create a test project or navigate to an existing project where you want to use your package:
mkdir test-project
cd test-project
npm init -y
Link your package into this project:
npm link my-utility-package
Replace my-utility-package with the actual name from your package's package.json file. This command creates a symlink in the test project's node_modules directory that points to your package. You can verify this by opening your node_modules directory and finding your package name there.
Before using your package, you need to configure your test project to support ES modules. Update the test project's package.json to include:
{
"type": "module"
}
The type field set to module tells Node.js to treat .js files as ES modules, allowing you to use import statements.
Now you can use your package in the test project. Create a test.js file:
// test.js
import { greet, add } from 'my-utility-package';
console.log(greet('Developer'));
console.log('2 + 3 =', add(2, 3));
Run the test file:
node test.js
You'll see output from your package:
Hello, Developer! Welcome to my utility package.
2 + 3 = 5
The advantage of npm link is that changes you make to your package code are immediately reflected in the linked project after rebuilding. When you edit TypeScript files in your package, run npm run build to recompile, and the changes will be available in the linked project without reinstalling or re-linking. This makes it excellent for active development and testing.
When you're done testing, you can remove the link from your test project:
npm unlink my-utility-package
To remove the global link entirely:
cd /path/to/my-utility-package
npm unlink
This removes the symlink from your global node_modules directory.
Now that you understand npm link, let's explore an alternative method using file paths.
Testing Your Package with File Paths
Instead of using npm link, you can install your package directly using a file path. This method doesn't require creating global symlinks and works well for one-time testing.
Navigate to your test project directory:
cd /path/to/test-project
Install your package using an absolute file path:
npm install /Users/your-username/projects/my-utility-package
Replace /Users/your-username/projects/my-utility-package with the absolute path to your package directory. You can find the absolute path by running pwd in your package directory on macOS or Linux, or cd on Windows.
npm copies your package into the node_modules directory and adds it to your package.json dependencies:
{
"dependencies": {
"my-utility-package": "file:../my-utility-package"
}
}
The path in package.json may show as a relative path, but npm resolves it correctly based on where you installed from.
You can now use the package exactly as you did with npm link:
import { greet, add } from 'my-utility-package';
console.log(greet('Developer'));
The key difference between this method and npm link is how updates work. When you use a file path, npm creates a copy of your package in node_modules. If you make changes to your original package code, you need to rebuild with npm run build and then reinstall it in your test project to see those changes:
npm install /Users/your-username/projects/my-utility-package
This reinstalls the package with your latest changes.
You can also use relative paths if your test project and package are in predictable locations:
npm install ../my-utility-package
This works when your test project and package are sibling directories. However, absolute paths are more reliable because they work regardless of where you run the command.
When you're done testing, you can uninstall the package normally:
npm uninstall my-utility-package
This removes the package from node_modules and from your package.json dependencies.
Both the npm link and file path methods have their uses. The link method is better for active development when you're making frequent changes, while the file path method is simpler for quick testing or CI/CD environments.
Step 5 — Understanding Package Manager Compatibility
When using npm link or file-based installs, you might wonder whether you can mix package managers like npm, yarn, and pnpm.
The short answer is: mixing package managers with npm link is not recommended and can cause problems.
Here's why: each package manager has its own way of managing dependencies and symlinks. When you run npm link, npm creates symlinks in npm's global directory structure. If you then try to use yarn or pnpm in the same project, they may not recognize or properly resolve these npm-created symlinks.
For example, if you create a link with npm:
npm link my-utility-package
And then try to install dependencies with yarn in the same project:
yarn install
yarn may remove or not properly recognize the symlink created by npm, leading to module resolution errors.
The same issue occurs with pnpm, which uses a unique approach to node_modules structure that's incompatible with symlinks created by other package managers.
Best practice: Choose one package manager for your project and use it exclusively. If your project uses yarn, use yarn link instead of npm link. If your project uses pnpm, use pnpm link instead.
The file-based installation method is more forgiving when mixing package managers, but you should still be consistent. When you install a package using a file path:
npm install /path/to/package
The package is copied into node_modules, so other package managers can work with it normally. However, the entry in package.json uses npm's file path format, which may not be fully compatible with other package managers' lock files.
To avoid these issues entirely:
- Use one package manager consistently throughout your project
- Check your project for existing lock files (
package-lock.json,yarn.lock, orpnpm-lock.yaml) to see which package manager is already in use - If working on a team, document which package manager the project uses
This consistency ensures that local linking and installation work reliably across your development environment.
Step 6 — Exploring Alternative Distribution Methods
Before publishing to the public npm registry, you should understand the different ways to distribute npm packages. Each method has different trade-offs for accessibility, privacy, and ease of use.
Public npm Registry
The public npm registry at npmjs.com is the default distribution method. Packages published here are available to anyone with internet access. This is the best choice for open source projects and packages you want to share with the community.
Private npm Registry
npm offers paid private packages on the public registry, allowing you to publish packages that only authorized users can access. Organizations can use this for internal libraries that shouldn't be publicly available.
Other private registry options include:
Verdaccio is a self-hosted npm registry that you can run on your own infrastructure. It's free and open source, making it popular for organizations that want full control over their package hosting.
GitHub Packages
GitHub Packages is a package hosting service integrated with GitHub repositories. You can publish npm packages to GitHub's registry and install them using npm with authentication. This keeps your packages close to your source code and leverages GitHub's access controls.
To publish to GitHub Packages, you configure your package.json with a scope matching your GitHub username or organization:
{
"name": "@your-username/my-utility-package",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}
The publishConfig field tells npm to publish to GitHub's registry instead of the public npm registry.
GitLab Package Registry
GitLab provides a similar package registry integrated with GitLab projects. The configuration process is comparable to GitHub Packages, using GitLab's npm registry URL.
Direct Git Installation
You can install npm packages directly from Git repositories without publishing to any registry. This is useful for private packages, testing unreleased versions, or when you don't need the overhead of a package registry.
npm supports several Git URL formats:
npm install git+https://github.com/username/repo.git
npm install git+ssh://git@github.com/username/repo.git
npm install github:username/repo
The first format uses HTTPS and may prompt for credentials. The second uses SSH, which is more convenient if you have SSH keys configured with GitHub. The third is a shorthand that npm expands to the full Git URL.
You can also specify a branch, tag, or commit:
npm install github:username/repo#branch-name
npm install github:username/repo#v1.2.0
npm install github:username/repo#commit-sha
The # symbol followed by a reference tells npm which version of the repository to install.
Tarball URLs
npm can install packages from .tgz tarball files hosted anywhere on the web:
npm install https://example.com/packages/my-package-1.0.0.tgz
This method works with any HTTP server, including Amazon S3 buckets, GitHub releases, or your own web server. You create the tarball using npm pack in your package directory.
Now that you understand the distribution options, you'll learn how to use packages from private GitHub repositories.
Conclusion
In this tutorial, you created a TypeScript npm package using npm init, configured TypeScript compilation with type definitions, and learned about the initialization options. You tested your package locally using two methods: npm link for creating symbolic links that reflect changes after rebuilding, and file-based installation using absolute paths for simpler one-time testing. You explored alternative distribution methods including GitHub Packages, private registries, and direct Git installation. You also learned how to install packages from private GitHub repositories using personal access tokens or SSH authentication. Finally, you understood why mixing package managers with npm link can cause issues and the importance of using a single package manager consistently.
This knowledge gives you a foundation for developing type-safe npm packages and distributing them through the channel that best fits your needs. Local testing helps you catch bugs and verify functionality, while understanding distribution options lets you choose between public sharing, private team access, or direct Git-based installation.
From here, you can enhance your package development workflow by:
- Adding automated tests with Jest or Vitest to verify your package works correctly
- Setting up ESLint and Prettier for consistent code formatting
- Creating comprehensive documentation in a README file
- Adding a watch mode to automatically rebuild on file changes during development
- Publishing your package to npm or GitHub Packages when it's ready for others to use
- Configuring CI/CD pipelines for automated publishing and version management


