So you’ve decided to open-source your project. Amazing! Bad news first: writing code is only the beginning. The information for library authors on the web is surprisingly fragmented, so I’ve decided to put together a list of things to keep in mind when open-sourcing a JS library:
- Decent docs, an OSS license, TypeScript definitions and a changelog are a must if you want anyone to use your library.
- Build setup of a library is different from that of an app — but even simpler, if you know what to do.
peerDependeniesare a thing (also, npm / yarn version resolution is a nightmare).
I’ll give you enough info on each point to get started without going too deep.
I assume you’re comfortable with babel and webpack (or any other build toolchain), and have followed some basic tutorial on publishing an npm package. I also suggest you host your code on GitHub — it has a ton of features useful for OSS, and any other choice is just bizarre in 2021. Let’s pick up where you’ve written some code, pushed it to a public github repo, set up your
main, and got
npm publish to pass. There’s still a bumpy ride ahead.
Your library will be used by people who have no idea what it’s supposed to do, and how it works. For a small project, don’t block yourself into building a fancy docs website — a
readme.md file in the repo root is enough. Writing the actual docs is a better way to spend your time. Make sure to include:
- What problem does your project solve? Does your library do something I need?
- How to install it? Even if it’s
npm i my-project, don’t make me guess the package name.
- A basic usage example. Just something to copy-paste and get started with it.
- Full API docs. What can the library do? Does it cover all my use-cases? What arguments do I pass where? I shouldn’t have to read the source to answer these questions.
See markdown cheatsheet if you’re not fluent yet.
(I’m not a lawyer, but this is my understanding). A project without an explicit license is closed source by default — the users can look at the code, but can’t legally use it. Choose an open-source license, copy the text into a
LICENSE file to your repo, put the name in the
license field of
package.json, and probably mention it in the readme. MIT license is a good choice if you just want everyone to use your code and don’t have a strong opinion on everything must be open-source.
When building an app, you have some transpiler + bundler (probably babel + webpack, but anything goes) setup to turn your source into someting that runs in a browser. If you don’t want to make your users jump around patching webpack config, you need some of that, too. Exactly how you should package your code is a complex topic, but let me scratch the surface for you. TLDR:
- If you use extended JS (JSX / TS / whatever), or want to support older runtimes with zero setup, do a
babel(or friends) pass.
- Have a ES-module build for tree-shaking, and a legacy CommonJS build.
- Be clear about supported browsers / node versions. Too much = bloat, too little = broken apps for users who don’t do extra setup (they won’t).
- Never use global polyfills, and prefer well-supported APIs when possible.
- Don’t bother bundling.
Just a touch deeper:
The code you ship must be standard ES to “just work” for your users. Convert JSX / TypeScript / Vue SFC / other fancy syntax down to JS with
tsc, or whatever
esbuild you enjoy, and point
main to the built version instead of your raw source.
The exact ES target (ES2020 / 6 / 5) is up to you — pick a browser / node target and stick to it. Your users can transpile further down, if they’re determined, but undoing an unnecessary transform is next to impossible — it becomes bloat in the final app. Also, some babel transforms (like unicode regex) are very verbose — have a look at the generated code once in a while.
The most important question is what to do with
import / export. Read on.
If your library runs in a browser, and it does more than one thing, you’d better support tree shaking — otherwise, every app using your library will ship useless dead code to the end users’ browsers. To get started, create a modular build:
@babel/preset-envconfig to preserve
import / export
"sideEffects": falseor list your side-effect modules explicitly in
- Point the non-standard
package.jsonto the resulting entrypoint.
Now a module-capable bundler can pick it up and remove unused code from the final bundle. Cool.
However, node <= 12 does not support import / export syntax without a flag. I’m lost in all the new
"type": "module" /
".mjs", but shipping a fallback CommonJS build works fine in all node versions and older bundlers. For a library that works both client- and server-side, keep a CommonJS (
exports / require) version (generated with a pass of babel with
modules: 'commonjs'), referenced in
main field of
There’s more to tree shaking — some patterns are not tree-shakable, it affects your API choices, complex topic. We’ll save for it later — supporting basic tree-shaking with ES modules is always a good start.
Should your library include the polyfills for recent browser APIs you use? It’s a surprisingly debatable issue among OSS authors. Problem, short version: in most app setups,
babel doesn’t process
node_modules, possibly breaking the final app in older browsers. But if you include a polyfill, removing it is from an app that only targets modern browsers is super hard. Also, the final bundle is likely to contain duplicate polyfills of one API.
Never include global polyfills that patch
window— they can clash with other global polyfills, and are not tree-shakable.
- Use helper functions (aka ponyfills) like
export function startsWithif for code reuse.
- Prefer well-supported APIs if possible — using
str.indexOf(...) === 0instead of
startsWithis not that hard.
- Clearly say what targets you support in the readme. Don’t pretend to support IE11 if you’re not very serious. Maybe provide instructions on setting up
babel-loaderto process your library.
You could bundle your code, but I think it creates more problems than it solves at the start. How to exclude your dependencies from the bundle? What
libraryTarget do you need? Are we sure bundling does not accidentally create non-tree-shakable logic? I’d stick with babel CLI and ship the code as separate JS files:
npx babel src --out-dir dist.
TypeScript is a major player in the JS ecosystem these days. Libraries without TS types explode in TS projects with
Could not find a declaration file for module '...', forcing users to either
@ts-ignore it or slap together custom ambient declarations. Some lazier developers will probably move to the next library, and I won’t blame them.
Shipping TS types is actually easy: if you write in TypeScript,
tsc --declaration (
--declarationOnly if you build with babel, see docs) into your build folder. If you write pure JS, it’s even easier — just write a custom
index.d.ts file describing your library, and copy it to build folder. Now point
types field in
package.json to the declaration entry point, and you’re all set! Don’t worry about
@types/* pacakges for now. See full TS docs on publishing if you have any trouble.
I don’t know much about Flow, and no one ever asked me to support it, but if you’re a fan, see SO tips on doing that.
In app development
npm i) vs
npm i --dev) is not a real issue — sure,
dev is for your build pipeline,
dependencies for real runtime libraries, but it mostly works fine if you mess up. The difference is critical for libraries, though:
devDependenciesare not installed after
npm i your-lib.
dependenciesare automatically installed along with your package. If the user (or some other package) requests a different version of the same dependency, they may get duplicated, but at least it usually works.
peerDependenciesallow you to reference a package explicitly installed by your users. This ensures the dependency instance is shared between user code and your library, which is crucial for plugins — react components, express middlewares, etc. In effect, this forces a single dependency version per app — your users can’t upgrade to react 18 until you support it. Assume the users have to install peers manually — sure, npm 7+ installs them automatically, but yarn and pnpm don’t. People hate manually installing stuff they don’t care about to get the project to build, so don’t overuse peers.
- bundledDependencies and optionalDependencies? You don’t need them.
Basic guideline: all the runtime dependencies go into
dependencies. Plugins should put the main library into
I install your library, and I’m happy with it. Some time later, it’s friday evening and I can’t get real job done any more. I decide to update my dependencies, and discover that your library moved from
- Have you fixed some important bugs, so that I need to update right now?
- Have you added new features I might enjoy?
- What’s the breaking change in v2, and how do I update?
To answer these questions, I’d love to see a changelog saying what changed in every version since
1.0.3. Both GH releases and a
CHANGELOG.md in the root work fine. Otherwise, I’ll have to read the commit / PR list, which is likely to make me very sad.
So, here’s my list of stuff to keep in mind when publishing a JS library:
- A readme with a problem statement, installation command, hello world example, and full API docs.
- A license: full text in project root, name in the readme and in
- TS typings (unless it’s a CLI tool).
- Changelog in GH releases or a
Plugins make the master library a
- Transpile the code to standard JS.
- Running in browsers? Ship es-modules entrypoint in
modulesfor tree shaking.
- Running on node? Ship CommonJS entrypoint in
- Avoid global polyfills.
- Don’t bundle.