Have you ever written a piece of code that could benefit other developers — or even your future self—over and over again? Perhaps it’s a utility function, a library, or a set of reusable tools you find yourself copying and pasting across multiple projects.
This might be the perfect candidate for an NPM package!
In this article, we’ll walk step-by-step through creating a simple Node.js package in both JavaScript and TypeScript, setting up best practices such as testing and versioning, and then publishing it to npmjs.com. 🚀
Table of Contents
1. Setting Up Your Project
2. Writing Your Package Code
3. Testing Your Package
4. Versioning Best Practices
5. Publishing Your Package
6. Common Features and Best Practices
7. Use Cases
8. Creating Scripts to Automate Common Tasks
1. Setting Up Your Project
1.1 Prerequisites
- Node.js (v22.12.0 LTS is recommended as of January 2025, v18 LTS is the oldest version that is still maintained).
- npm (comes bundled with Node).
- A npmjs.com account for publishing your package.
1.2 Initialize Your Project
Create a new directory for your package:
mkdir my-awesome-package
cd my-awesome-package
Initialize a new Node.js project:
npm init
This command will ask several questions to help create a package.json
file, the heart of your Node.js package. Key fields to pay attention to:
- name: The name of your package (must be unique in the npm registry).
-
version: The version of your package, often starting at
1.0.0
. - description: A brief explanation of your package.
-
entry point: The main file (often
index.js
ordist/index.js
). - author: Your name or organization.
- license: The license type (e.g., MIT).
You can skip the Q&A by running npm init -y
to get a default package.json
file.
2. Writing Your Package Code
We’ll explore two approaches: plain JavaScript and TypeScript. Choose whichever suits your project needs—or maintain both if you like.
2.1 Plain JavaScript Version
Create a file named index.js
in your project root:
// index.js
/**
* Returns a greeting message
* @param {string} name - The name to greet
* @return {string} A greeting message
*/
function greet(name) {
return `Hello, ${name}!`;
}
// Export the function as a module
module.exports = greet;
2.2 TypeScript Version
2.2.1 Install and Initialize TypeScript
Install Typescript as a development dependency:
npm install --save-dev typescript
Initialize a tsconfig.json
:
npx tsc --init
2.2.2 A Recommended tsconfig.json
Replace the generated tsconfig.json
with a configuration tailored for your needs, here we configure for modern Node.js (v22.12.0 LTS):
{
"compilerOptions": {
"target": "ES2020", // Modern JavaScript features
"module": "CommonJS", // Node.js module system
"rootDir": "src", // Source files directory
"outDir": "dist", // Compiled output directory
"strict": true, // Enable all strict type-checking options
"esModuleInterop": true, // Better compatibility with CommonJS modules
"skipLibCheck": true, // Skip type checking of declaration files for faster builds
"forceConsistentCasingInFileNames": true, // Enforce consistent file name casing
"declaration": true, // Generate .d.ts files for type definitions
"declarationMap": true // Generate source maps for declarations
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}
This ensures compatibility with modern JavaScript and enforces strict type-checking, improving code quality.
2.2.3 Create Your Source File
Create a src
directory and add index.ts
:
// src/index.ts
/**
* Returns a greeting message
* @param name - The name to greet
* @returns A greeting message
*/
export function greet(name: string): string {
return `Hello, ${name}!`;
}
2.2.4 Update package.json
We need to point main
at the compiled JavaScript file in dist
, and also specify where the type definitions live via the "types"
field. We’ll also add a build
script:
{
"name": "my-awesome-package",
"version": "1.0.0",
"description": "A package that greets the user.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "jest"
},
"author": "Your Name",
"license": "MIT"
}
By specifying "types": "dist/index.d.ts"
, consumers of your package automatically get the TypeScript definitions.
Now, you can compile your TypeScript source by running:
npm run build
3. Testing Your Package
Testing is not mandatory for publishing but is highly recommended to ensure reliability. We’ll demonstrate using Jest for both JavaScript and TypeScript.
3.1 Install Jest
npm install --save-dev jest
For TypeScript, also install ts-jest
and type definitions:
npm install --save-dev ts-jest @types/jest
3.2 Configure Jest (TypeScript Only)
Initialize Jest with TypeScript support:
npx ts-jest config:init
This creates a jest.config.js
(or jest.config.ts
) file tailored for TypeScript. Typically, it might look like:
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node'
};
3.3 Write Your Tests
Create a test
directory. Let’s name our test file greet.test.js
or greet.test.ts
, depending on your language choice.
3.3.1 JavaScript Test Example
// test/greet.test.js
const greet = require('../index'); // Using JavaScript's require
test('returns a greeting with the given name', () => {
expect(greet('Alice')).toBe('Hello, Alice!');
});
3.3.2 TypeScript Test Example
// test/greet.test.ts
import { greet } from '../src/index'; // or '../dist/index' after build
test('returns a greeting with the given name', () => {
expect(greet('Alice')).toBe('Hello, Alice!');
});
3.4 Update package.json for Testing
Ensure your package.json
includes:
{
"scripts": {
"build": "tsc",
"test": "jest"
}
}
Now you can build and test your project via:
npm run build
npm test
4. Versioning Best Practices
Node packages typically follow SemVer (Semantic Versioning):
- MAJOR for incompatible API changes.
- MINOR for adding functionality in a backward-compatible manner.
- PATCH for backward-compatible bug fixes.
Example: 1.0.0
(Major.Minor.Patch)
To increment your package version (and create a Git tag if you’re using Git):
npm version patch # e.g., 1.0.0 -> 1.0.1
npm version minor # e.g., 1.0.1 -> 1.1.0
npm version major # e.g., 1.1.0 -> 2.0.0
5. Publishing Your Package
5.1 Log in to npm
Before you can publish, authenticate with npm:
npm login
Enter your credentials and/or token. If you haven’t created an account yet, sign up here.
Alternatively, you can setup your .npmrc
to handle the authentications:
echo //registry.npmjs.org/:_authToken=MY_TOKEN > ~/.npmrc
You can change the registry path as needed, depending on your NPM repository instance. For instance, for GitHub you may do:
echo //npm.pkg.github.com/:_authToken=MY_TOKEN > ~/.npmrc
5.2 Publish
For a plain JavaScript package:
npm publish
For a TypeScript package, build your code before publishing:
npm run build
npm publish
Your package is now live on the npm registry, and anyone can install it with:
npm install my-awesome-package
If your package name is scoped (e.g., @your-scope/my-awesome-package
), you might need:
npm publish --access public
(--access public
is required for scoped packages to be publicly available.)
6. Common Features and Best Practices
6.1 README.md
A clear README.md
is essential for explaining your package. Include:
- Installation Instructions
npm install my-awesome-package
- Basic Usage
// JavaScript:
const greet = require('my-awesome-package');
console.log(greet('Alice')); // Hello, Alice!
// TypeScript:
import { greet } from 'my-awesome-package';
console.log(greet('Alice')); // Hello, Alice!
- Advanced Examples: Show more use cases, configuration, or integration details.
- API Documentation: If your package has multiple methods or classes, document them thoroughly.
6.2 LICENSE
Including a license clarifies how your package can be used. Define it in:
- A
LICENSE
file at the root. - The
license
field inpackage.json
.
6.3 GitHub Repository
While not strictly required, hosting your package on a platform like GitHub helps with:
- Collaboration (issues, pull requests)
- Version Control (Git tags, branches)
- Automated CI (running tests on each commit)
6.4 Linting
Use a linter like ESLint for consistent coding standards.
npm install --save-dev eslint
npx eslint --init
Configure your lint rules, then run:
npm run lint
6.5 Continuous Integration (CI)
For open-source or team projects, consider GitHub Actions, Travis CI, or similar. This ensures tests run automatically on every commit, increasing confidence and catching issues early.
6.6 Adding Dependencies and Managing Dependency Files
As your package grows, you might need to incorporate other libraries or tools to enhance its functionality. Managing dependencies effectively ensures that your package remains robust, maintainable, and easy for others to use.
6.6.1. Adding Dependencies
Dependencies are packages required for your package to run correctly. DevDependencies are packages needed only during development, such as testing frameworks or build tools.
- To add a runtime dependency:
npm install lodash
This command adds the lodash
library as a dependency and updates your package.json
:
{
"dependencies": {
"lodash": "^4.17.21"
}
}
- To add a development dependency:
npm install --save-dev eslint
This adds eslint
as a devDependency:
{
"devDependencies": {
"eslint": "^8.0.0"
}
}
6.6.2. Managing Dependency Files
package.json
: This file lists all your dependencies and devDependencies. Ensure that all necessary packages are correctly listed to avoid missing dependencies during installation.package-lock.json
: Automatically generated when you install dependencies, this file locks the versions of your dependencies to ensure consistent installations across different environments. Do not manually edit this file; instead, let npm manage it.
6.6.3. Best Practices
- Keep Dependencies Updated: Regularly update your dependencies to incorporate the latest features and security patches. You can use tools like npm-check-updates to assist with this.
npm install -g npm-check-updates
ncu -u
npm install
Minimize Dependencies: Only include packages that are essential for your package’s functionality. Fewer dependencies reduce the risk of conflicts and vulnerabilities.
Use Peer Dependencies When Appropriate: If your package is a plugin or extension that relies on a host package (like React), specify it as a peer dependency to avoid version conflicts.
npm install --save-peer react
Update your package.json
:
{
"peerDependencies": {
"react": "^17.0.0"
}
}
6.6.4. Example: Adding a Dependency
Suppose you want to use axios
for making HTTP requests in your package.
- Install axios as a dependency:
npm install axios
- Use axios in your code:
// src/index.ts
import axios from 'axios';
/**
* Fetches user data from an API
* @param userId - The ID of the user
* @returns User data
*/
export async function fetchUserData(userId: number): Promise<any> {
const response = await axios.get(`https://api.example.com/users/${userId}`);
return response.data;
}
7. Use Cases
-
Utility Packages
- E.g., date-formatting libraries to simplify working with dates.
-
UI Components
- E.g., a set of React or Vue components published as a standalone package.
-
CLI Tools
- E.g., command-line utilities for scaffolding projects or automating tasks.
-
Libraries for APIs
- E.g., wrappers for third-party APIs or microservices, making them easy to integrate with Node.js.
8. Creating Scripts to Automate Common Tasks
To streamline your development workflow, it's beneficial to define custom scripts in your package.json
. These scripts can handle tasks like building, testing, deploying, and versioning your package with simple commands.
8.1. Benefits of Custom Scripts
- Automation: Reduce manual steps by automating build and deployment processes.
- Consistency: Ensure that tasks are performed the same way every time.
- Modularity: Create reusable scripts that can be combined for complex workflows.
- Efficiency: Save time by chaining multiple commands into a single script.
8.2. Example: Deploying to Development and Production
Let's create two deployment scripts: deploy:dev
for development environments and deploy:prod
for production. These scripts will handle tasks such as building the project, tagging the Git repository, versioning, and deploying the package.
8.2.1. Defining the Scripts in package.json
Open your package.json
and add the following scripts under the "scripts"
section:
{
"scripts": {
"build": "tsc",
"test": "jest",
"version": "npm version patch",
"tag:dev": "git tag -a v$(node -p \"require('./package.json').version\") -m \"Dev deployment v$(node -p \"require('./package.json').version\")\"",
"deploy:dev": "npm run build && npm run test && npm run version && npm run tag:dev && git push && git push --tags && npm publish --tag dev",
"tag:prod": "git tag -a v$(node -p \"require('./package.json').version\") -m \"Production deployment v$(node -p \"require('./package.json').version\")\"",
"deploy:prod": "npm run build && npm run test && npm run version && npm run tag:prod && git push && git push --tags && npm publish"
}
}
8.2.2. Breakdown of the Scripts
-
build
: Compiles the TypeScript code into JavaScript using the TypeScript compiler. -
test
: Runs the Jest test suite to ensure your package is working correctly. -
version
: Increments the patch version inpackage.json
following Semantic Versioning. -
tag:dev
/tag:prod
: Creates a Git tag corresponding to the current version for development or production deployments. -
deploy:dev
:- Builds the project.
- Tests the build.
- Versions the package.
- Tags the commit for a development deployment.
- Pushes the commits and tags to the remote repository.
-
Publishes the package to npm with the
dev
tag.
deploy:prod
: Similar todeploy:dev
but publishes the package without a tag, making it the latest stable release.
8.3. Running the Deployment Scripts
With the scripts defined, deploying becomes straightforward:
- Deploy to Development:
npm run deploy:dev
This command will build your project, run tests, increment the patch version, create a Git tag, push changes, and publish the package with the dev
tag.
- Deploy to Production:
npm run deploy:prod
This command performs the same steps as deploy:dev
but publishes the package as the latest stable version without an additional tag.
8.4. Making Scripts Modular
By breaking down tasks into smaller, reusable scripts (like build
, test
, version
, and tagging), you can compose complex workflows easily. This modularity allows you to maintain and update individual steps without affecting the entire deployment process.
For example, if you need to change how the project is built, you only need to update the build
script without modifying the deployment scripts.
8.5. Additional Automation Tips
-
Pre/Post Hooks: Utilize npm's pre and post script hooks to run tasks automatically before or after specific scripts. For instance, you can add a
prepublishOnly
script to ensure tests pass before publishing.
{
"scripts": {
"prepublishOnly": "npm run test && npm run build"
}
}
Environment Variables: Manage different environments (development, staging, production) by using environment variables within your scripts or leveraging packages like cross-env for cross-platform compatibility.
Error Handling: Incorporate error handling in your scripts to halt the process if a critical step fails, ensuring that incomplete or faulty deployments do not occur.
In Review
Publishing an NPM package may feel daunting, but the fundamentals are straightforward:
- Initialize your project with
npm init
. - Write your code in either JavaScript or TypeScript (v5.7.2 recommended).
- Add tests to ensure your package remains reliable.
- Follow SemVer for version management.
- Publish your package with
npm publish
.
By following these best practices you’ll have a package that is reliable, easy to maintain, and simple for others to consume. 💻
Top comments (0)