Skip to content

frehner/modern-guide-to-packaging-js-library

Repository files navigation

The Modern Guide to Packaging your JavaScript library

English | 简体中文

Context about this guide

This guide is written to provide at-a-glance suggestions that most libraries should follow, while also giving additional information if you want to understand why a suggestion is made or if you feel like you need to deviate from these suggestions. This guide is meant for libraries and not for applications (apps).

To emphasize, this is a list of suggestions and is not meant to be definitive list that all libraries must follow, or to provide flame bait for libraries that do not follow these suggestions. Each library is unique and there are probably good reasons a library may chose to not implement any given suggestion.

Finally, this guide is not meant to be specific to any particular bundler - there already exist many guides on how to set configs in specific bundlers. Instead, we want to focus on things that apply to every library and bundler (or lack of bundler).


Tooling

Use tools to validate important settings
  • publint.dev validates important settings with your package.json and suggests improvements if it finds them
  • arethetypeswrong validates that your TypeScript types are output and configured correctly

Output to esm, cjs, and umd formats

Supporting the whole ecosystem

esm is short for "EcmaScript module."

cjs is short for "CommonJS module."

umd is short for "Universal Module Definition," and can be run by a raw <script> tag, or in CommonJS module loaders, or by AMD module loaders.

Without getting into the flame wars that generally happen around esm and cjs formats, esm is considered "the future" but cjs still has a strong hold on the community and ecosystem. esm is easier for bundlers to correctly tree shake, so it is especially important for libraries to have this format. It's also possible that some day in the future your library only needs to output to esm.

You may have noticed that umd is already compatible with CommonJS module loaders - so why would you want to have both cjs and umd output? One reason is that CommonJS files generally perform better when conditionally depended on compared to umd files; for example:

if (process.env.NODE_ENV === "production") {
  module.exports = require("my-lib.production.js");
} else {
  module.exports = require("my-lib.development.js");
}

The above example, when used with CommonJS modules, will only end up with either the production or development bundle. However, with a UMD module, it may be the case that a developer would end up with both bundles. Refer to this discussion for more information.

Finally, if your library is stateful, be aware that this does open the possibility of your library running into the dual package hazard, which can occur in situations where a developer ends up with both a cjs and esm version of your library in their application. The "dual package hazard" article linked above describes some ways to mitigate this issue, and the module condition in package.json#exports can also help prevent this from happening.

Output to multiple files

Better tree shaking by maintaining the file structure

If you use a bundler or transpilier in your library, it can be configured to output files in the same way that they were authored. This makes it easier to mark specific files as having side effects, which helps the developer's bundler with tree shaking. Refer to this article for more details.

An exception is if you are making a bundle meant to be used directly in the browser without any bundler (commonly, these are umd bundles but could also be modern esm bundles as well). In this case, it is better to have the browser request a single large file than need to request multiple smaller ones. Additionally, you should minify the bundle and create sourcemaps for it.

To Minify or Not to Minify

Determine your preferred level of minification

There are certain levels of minification you can apply to your library, and depending on how aggressive you want to be will determine how small your code will be once it's finally through a developer's bundler.

For example, most bundlers are already configured to remove whitespace and other easy optimizations, even from an NPM module (in this case, your library). According to Terser - a popular JavaScript mangler/compressor - that type of compression can reduce your bundle's final size by up to 95%. In some cases, you may be happy with those savings with no effort on your part.

However, there are additional savings that can occur if you were to run a minifier on your library before publishing, but doing so requires deeply understanding the settings and side-effects of your minifer. These type of compressions are generally not run by minifiers on NPM modules, and therefore you will miss out on those savings unless you do it yourself. Refer to this issue for additional information.

Finally, if you are creating a bundle intended to be used directly in the browser without a bundler (commonly, these are umd bundles but could also be modern esm bundles as well), you should always minify your bundle, create sourcemaps for it, and output to a single file.

Create sourcemaps

When using a bundler or transpiler, generate sourcemaps

Any sort of transformation of your source code to a bundle will produce errors that point at the wrong location in your code. Help your future self out and create sourcemaps, even if your transformations are small.

Create TypeScript types

Types improve the developer experience

As the number of developers using TypeScript continues to grow, having types built-in to your library will help improve the developer experience (DX). Additionally, devs who are not using TypeScript also get a better DX when they use an editor that understands types (such as VSCode, which uses the types to power its Intellisense feature).

However, creating types does NOT mean you must author your library in TypeScript.

One option is to continue using JavaScript in your source code and then also supplement it with JSDoc comments. You would then configure TypeScript to only emit the declaration files from your JavaScript source code.

Another option is to write the TypeScript type files directly, in an index.d.ts file.

Once you have the types file, make sure you set your package.json#exports and package.json#types fields.

Externalize frameworks

Don't include a copy of React, Vue, etc. in your bundle

When building a library that relies on a framework (such as React, Vue, etc.) or is a plugin for another library, you'll want to add that framework to your bundler's "externals" (or equivalent) configuration. This will make it so that your library will reference the framework but will not include it in the bundle. This will prevent bugs and also reduce the size of your library's package.

You should also add that framework to your library's package.json's peer dependencies, which will help developers discover that you rely on that framework.

Target modern browsers

Use modern features and let devs support older browsers if needed

This article on web.dev makes a great case for your library to target modern features, and offers guidelines on how to:

  • Enable developers to support older browsers when using your library
  • Output multiple bundles that support various levels of browser support

As one example, if you're transpiling from TypeScript, you could create two versions of your package's code:

  1. An esm version with modern JavaScript generated by setting "target"="esnext" in your tsconfig.json
  2. A umd version with more broadly-compatible JavaScript generated by setting "target"="es5" in your tsconfig.json

With these settings, most users will get the modern code, but those using older bundler configurations or loading the code using a <script> tag will get the version with additional transpilation for older browser support.

Transpile if necessary

Turn TypeScript or JSX into function calls If your library's source code in in a format that requires transpilation, such as TypeScript, React or Vue components, etc., then your output should be transpiled. For example:
  • Your TypeScript library should output JavaScript bundles
  • Your React components like <Example /> should output bundles that use jsx() or createElement() instead of JSX syntax.

When transpiling this way, make sure you create sourcemaps as well.

Keep a changelog

Track updates and changes

It doesn't matter whether it's through automatic tooling or through manual process, as long as developers have a way to see what has changed and how it affects them. Ideally, every change to your library's version has a corresponding update in your changelog.

Split out your CSS files

Enable devs to only include the CSS they need

If you are creating a CSS library (like Bootstrap, Tailwind, etc.), it may be easier to provide a single CSS bundle that includes all the functionality that your library provides. However, even in that situation, your CSS bundle may end up becoming large enough that it affects the performance of the devs' sites. To help prevent that, libraries generally provide methods of generating a CSS bundle that only includes the necessary CSS for what the developer is using (for example, see how Bootstrap and Tailwind do it).

If CSS is only a part of what your library exposes (for example, a component library that has default styles), then it is ideal if you separate out your CSS into individual bundles per component that are imported when the corresponding component is used. One example of this is react-component.

package.json settings

There are a lot of important settings and fields to talk about in package.json; I will highlight the most important ones here, but be aware that there are additional fields that you can set as well.

Set the name field

Give a name to your library

The name field will determine the name of your package on npm, and therefore the name that developers will use to install your library.

Note that there are restrictions on what you can name your library, and additionally you can add a "scope" if your library is part of an organization. Refer to the name docs on npm for more details.

The name and the version fields combine to create a unique identifier for each iteration of your library.

Set the version field

Publish updates to your library by changing the version

As noted in the name section, the name and the version combine to create a unique identifier for your library on npm. When you make updates to the code in your library, you can then update the version field and publish to allow developers to get that new code.

Libraries are encouraged to use a versioning strategy called semver, but note that some libraries choose to calver or their own unique versioning strategy. Whichever strategy you choose to use, you should document it so that developers understand how your library's versioning works.

You should also keep track of your changes in a changelog.

Define your exports

exports define the public API for your library

The exports field on package.json - sometimes called "package exports" - is an incredibly useful addition, though it does add some complexity. The two most important things that it does is:

  1. Defines what can and cannot be imported from your library, and what the name of it is. If it's not listed in exports, then developers cannot import/require it. In other words, it acts like a public API for users of your library and helps define what is public and what is internal.
  2. Allows you to change which file is imported based on conditions (that you can define), such as "Was the file imported or required? Does the developer want a development or production version of my library?" etc.

There are some good docs from the NodeJS team and the Webpack team on the possibilities here. I'll provide one example that covers the most common use-cases:

{
  "exports": {
    ".": {
      "module": "./dist/index.mjs",
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      },
      "default": "./dist/index.mjs"
    },
    "./package.json": "./package.json"
  },
  "types": "./dist/index.d.ts"
}

Let us dive into the meaning of these fields and why I chose this specific shape:

  • "." indicates the default entry for your package
  • The resolution happens from top to bottom and stops as soon as a matching field is found; the order of entries is very important
  • The module field is an "unofficial" field that is supported by bundlers like Webpack and Rollup. It should come before import and require, and point to an esm-only bundle -- which can be the same as your original esm bundle if it's purely esm. As noted in the formats section, it is meant to help bundlers only include one copy of your library, no matter if it was imported or requireed. For a deeper dive and the reasoning behind this decision, you can read more here, here, and here.
  • The import field is for when someone imports your library.
  • The require field is for when someone requires your library.
  • The default field is used as a fallback for if none of the conditions match. While it may not be used at the moment, it's good to have it for "unknown future situations".

If a bundler or environment understands the exports field, then the package.json's top-level main, types, module, and browser fields are ignored, as exports supersedes those fields. However, it's still important to set those fields, for tools or runtimes that do not yet understand the exports field.

If you have a "development" and a "production" bundle (for example, you have warnings in the development bundle that don't exist in the production bundle), then you can also set them in the exports field with "development" and "production". Note that some bundlers like webpack and vite will recognize these conditions automatically; however, while Rollup can be configured to recognize them, that is something that you would have to instruct developers to do in their own bundler config.

(Note that while the "types" field is covered in a different section, it is included in the above snippet for people who are copy-pasting the example. Even though we have two separate "types" fields inside of the "export" field, the "types" field is also required at the root of the object for full backwards compatibility, as tools like arethetypeswrong will fail your package otherwise.)

List the files to be published

files defines which files are included in your NPM package

The files field indicates to the npm CLI which files and folders to include when you package your library to be put on NPM's package registry.

For example, if you transform your code from TypeScript into JavaScript, you probably don't want to include the TypeScript source code in your NPM package. (Instead, you should include sourcemaps)

Files can take an array of strings (and those strings can include glob-like syntax if needed), so generally it will look like:

{
  "files": ["dist"]
}

Be aware that the files array doesn't accept a relative specifier; writing "files": ["./dist"] will not work as expected.

One great way to verify you have set the files field up correctly is by running npm publish --dry-run, which will list off the files that will be included based on this setting.

Set the default module type for your JS files

type dictates which module system your .js files use

Runtimes and bundlers need a way to determine what type of module system your .js files are using - ESM or CommonJS. Because CommonJS came first, that is the what bundlers will assume by default, but you can control it by adding "type" your package.json.

Your options are either "type":"module" or "type":"commonjs", and though you can leave it blank (to default to CommonJS) it's highly recommended that you set it to one or the other to explicity declare which one you're using.

Note that you can have a mix of module types in the project, through a couple of tricks:

  • .mjs files will always be ESM modules, even if your package.json has "type": "commonjs" (or nothing for type)
  • .cjs files will always be CommonJS modules, even if your package.json has "type": "module"
  • You can add additional package.json files that are nested inside of folders; runtimes and bundlers look for the nearest package.json and will traverse the folder path upwards until they find it. This means you could have two different folders, both using .js files, but each with their own package.json set to a different type to get both a CommonJS- and ESM-based folder.

Refer to the excellent NodeJS documentation here and here for more information.

List which modules have sideEffects

Setting the sideEffects field enables tree shaking

Much a like creating a pure function can bring benefits, creating a "pure module" enables certain benefits as well; bundlers can do a much better job of tree shaking your library.

The way to communicate to bundlers which of your modules are "pure" or not is by setting the sideEffects field in package.json - without this field, bundlers have to assume that all of your modules are impure.

sideEffects can either be set to false to indicate that none of your modules have side effects, or an array of strings to list which files have side effects. For example:

{
  // all modules are "pure"
  "sideEffects": false
}

or

{
  // all modules are "pure" except "module.js"
  "sideEffects": ["module.js"]
}

So, what make a module "impure?" Some examples are modifying a global variable, sending an API request, or importing CSS, without the developer doing anything to invoke that action. For example:

// a module with side effects

export const myVar = "hello";

window.example = "testing";

By importing myVar, your module sets window.example automatically! For example:

import { myVar } from "library";

console.log(window.example);
// logs "testing"

In some cases, like polyfills, that behavior is intentional. However, if we wanted to make this module "pure", we could move the assignment to window.example into a function. For example:

// a "pure module"

export const myVar = "hello";

export function setExample() {
  window.example = "testing";
}

This is now a "pure module." Also note the difference in how things look on the developer's side of things:

import { myVar, setExample } from "library";

console.log(window.example);
// logs "undefined"

setExample();

console.log(window.example);
// logs "testing"

Refer to this article for more details.

Set the main field

main defines the CommonJS entry

main is a fallback for bundlers or runtimes that don't yet understand package.json#exports; if a bundler/environment does understand package exports, then main is not used.

main should point to a CommonJS-compatible bundle; it should probably match the same file as your package export's require field.

Set the module field

module defines the ESM entry

module is a fallback for bundlers or runtimes that don't yet understand package.json#exports; if a bundler/environment does understand package exports, then module is not used.

module should point to a ESM-compatible bundle; it should probably match the same file as your package export's module and/or import field.

Set additional fields for CDNs

Support CDNs like unpkg and jsdelivr

To enable your library to "work by default" on CDNs like unpkg and jsdelivr, you can set their specific fields to point to your umd bundle. For example:

{
  "unpkg": "./dist/index.umd.js",
  "jsdelivr": "./dist/index.umd.js"
}

Consider setting the browser field

browser points to a bundle that works in the browser

browser is a fallback for bundlers or runtimes that don't yet understand package.json#exports; if a bundler/environment does understand package exports, then browser is not used.

browser should point to an esm bundle that works in the browser. However, you'll only need to set this field if you are creating different bundles for browsers and servers (and/or other non-browser environments). If you're not creating multiple bundles for multiple environments, or if your bundles are "pure JavaScript" / "universal" and can be run in any JavaScript environment, then you don't need to to set the browser field.

If you do need to set this field, here's an excellent guide on the different ways you can configure it.

Note that the browser field shouldn't point to a umd bundle, as that would make it so that your library isn't tree shaked by bundlers (like Webpack) that prioritize this field over the others such as module and main.

Set the types field

types defines the TypeScript types

types is a fallback for bundlers or runtimes that don't yet understand package.json#exports; if a bundler/environment does understand package exports, then types is not used.

types should point to your TypeScript entry file, such as index.d.ts; it should probably match the same file as your package export's types field.

List your peerDependencies

If you rely on another framework or library, set it as a peer dependency

You should externalize any frameworks you rely on. However, in doing so, your library will only work if the developer installs the framework you need on their own. One way to help them know that they need to install the framework is by setting peerDependencies - for example, if you were building a React library, it would potentially look like this:

{
  "peerDependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}

Refer to this article for more details.

You should also document your reliance on these dependencies; for example, npm v3-v6 does not install peer dependencies, while npm v7+ will automatically install peer dependencies.

State which license your library falls under

Protect yourself and other contributors

An open source license protects contributors and users. Businesses and savvy developers won’t touch a project without this protection.

That quote comes from Choose a License, which is also a great resource for deciding which license is right for your project.

Once you have decided on a license, the npm Docs for the license describe the format that the license field takes. An example:

{
  "license": "MIT"
}

Additionally, you can create a LICENSE.txt file in the root of your project and copy the license text there.


Special Thanks

A big thank you to the people who took the time out of their busy schedules to review and suggest improvements to the first draft of this document (ordered by last name):

  • Joel Denning @joeldenning
  • Fran Dios @frandiox
  • Kent C. Dodds @kentcdodds
  • Carlos Filoteo @filoxo
  • Jason Miller @developit
  • Konnor Rogers @paramagicdev
  • Matt Seccafien @cartogram
  • Nate Silva @natessilva
  • Cong-Cong Pan @SyMind

About

A guide to help ensure your JavaScript library is the most compatible, fast, and efficient library you can make.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published