Despite having experience in building and deploying JS apps, you might be surprised to find the many differences when it comes to building libraries. Along with a different release workflow, deploying an application to a server versus publishing a new version to a registry, JS libraries also differ in their tools and approaches. We were certainly surprised a few years back when we realized that we had to ditch webpack and move to rollup just because we wanted to build a library!
Application? Library? What’s The Difference?
The essential difference is easy to describe and understand - An application is an executable piece of software for users to use and interact with directly, while a library is a non-executable piece of software that users don’t interact with, but other libraries and/or applications rely on. Applications are used by users, but libraries are used by applications to add extra features and behaviors without the developers having to code them themselves.
Here’s an example of both:
An application could be the Node.js backend for your cat blog that is running on a server somewhere and processing all of the incoming requests. During development you wouldn’t want to code all of the low-level logic necessary to handle and respond to web requests, so you would probably use a library that does all of that for you - Express.js, for example
::: tip 💡 Essentially libraries are the reusable building blocks of applications.
Let's start with the more common option of the two - building an application. Unlike a library, an application must be executable in specific target environments.
If the app is a server backend, preparing it for execution is pretty simple because there's usually only one environment the server needs to run in - your chosen server (e.g., Ubuntu machine or maybe a specific kind of Docker container). Additionally, you don't need compilation or build steps because you can just run a Node.js script directly.
::: tip Exception: if you want to write in TypeScript or use new ECMAScript features not supported by your Node version, you will need a build step.
If you're building a frontend app, however, let's say a Single Page Application, preparation gets more complicated because this client-side app needs to be able to run on multiple different versions of various different browsers. Besides that, you will also need to compile your application, as it is highly unlikely that your source code can directly run as-is inside a browser.
Compiling a JS app for the web is the most common JS compilation use case, so the good news is that there are many tools and approaches to help you get this done. You often don’t even need to think about it since a framework you’re using might already have a stable compilation config baked into it. (If you’ve ever used something like Next.js or Nuxt.js, you’ll know that the framework comes with predefined build and run commands that compile everything correctly without your involvement 😉)
At Speckle, webpack is the preferred choice because, compared to some other build tools, it is very versatile and well supported, and you can configure it to work in any way you want. rollup is another good option that might not be as popular but allows for deep configuration and has a ton of community support through custom plugins and loaders. Vite is an interesting one that has appeared on the scene relatively recently. It may not be as popular, but it has a nice trick under its sleeve - It compiles development builds using esbuild, and production builds using rollup. This enables developers to significantly increase build speeds when working on the app on their local machines.
Whatever tool you use, the idea is the same: to take all your source code and assets and bundle it up into an application that can run in your chosen target environment(s).
Making It Work In The Target Environment
Making your app work in specific target environments is done by configuring your build tools. Whether you’re using webpack, rollup or Vite the main options you’ll need to pay attention to are the output format and target environment.
::: tip Note: Currently, the ESM support in Node.js is still pretty experimental, so for building a Node.js application (e.g., a server backend), we suggest outputting to CommonJS.
There are multiple reasonable options for building an application that is supposed to run in the browser. You could output your code in ESModules because browsers have been supporting that for a few years already, but if you want to reach the widest audience, you might want to use a format that even old browsers like IE11 can parse and execute.
In the past SystemJS and RequireJS were quite popular for bundling things for the web, but nowadays you should use whatever the default web output format for your bundling tool of choice is. For rollup that will likely be the iife format, and for webpack, it wraps all JS files in its own custom format to manage the state of all the modules.
es2015 to convert all of those new features back to older code that even IE11 can understand (which does come with a performance impact, however).
::: tip Note: not all build tools have the capability to do this kind of granular transpilation according to ECMAScript versions or browser versions built-in.
Thankfully, there is a tool that can be plugged into your build tool to do this transpilation and it’s called Babel (webpack; rollup)*. Not only can you tell Babel to transpile your code into an older ECMAScript version, but you can even tell it to target specific (kinds of) browsers or support specific ECMAScript features.
- If you’re using
esbuild, then you don’t need Babel and it doesn’t support it anyway. Configure the
target(https://esbuild.github.io/api/#target) property instead.
So, you need to be cognizant of how all of these contexts differ from one another and how to build something that can be used and will work correctly in all of them. Building an application with a specific target environment in mind is certainly simpler.
The main difference between a built app and built library is that the application is executable and can be run by Node.js or the browser, but the library is supposed to be imported and consumed like any other piece of code by other libraries and applications. Thus, you need the build result to be something that can be imported from other JS source code and processed by build tools that will build the final application relying on your library.
::: tip 💡 The library you build is possibly going to go through a build tool again once the final application that relies on your library is built. So, it’s important to use a format and overall architecture that these tools can understand and optimize according to modern best practices (e.g., tree-shake).
Making It Usable From Various Apps
import calls to import other modules and CJS relies on
require calls instead. Thus, to be able to import and use a library from your source code, you need that library to be in either CJS or ESM.
In the past, we have had to ditch webpack for rollup to build libraries: back then webpack did not have a way to output the build in CJS or ESM modules and even today this support is still experimental.
If interested in webpack, you would need some extra configuration since it outputs the resulting JS files in its own browser-oriented module format that resembles IIFE and there are no
import calls in there, even if the build is split apart into multiple chunks (files). If you tried to import this file from your ESM or CJS source code, you wouldn’t be able to.
With some extra configuration you can use something like the UMD format to get a functional library at the end. Still, if you want your library to be appropriately analyzable, tree-shakable and code-splitable then you’ll find this older format lacking.
Rollup, on the other hand, has support for CJS and ESM outputs that works very well and is easily configurable, and thus is a great choice for building a library.
::: tip 💡 Webpack, while great for building applications, is not as good at building libraries. Rollup, however, is a very good option for this.
Choosing Supported Output Formats
Unlike a web application, a JS library needs to be built to multiple formats to make it accessible and usable by most JS projects.
In rollup configuring this is trivial through the
output.format prop. You can configure multiple outputs each with a different format and the library consumers will have multiple options to pick from depending on their architecture.
::: tip 💡 The best practice nowadays is to ensure your build tool outputs your library in multiple output formats - in ESM, CJS and even UMD as a fallback.
Besides emitting different formats, you also need to make sure that your library’s
package.json is configured to tell Node.js and build tools that there are multiple formats available and to point them to the correct ones.
What follows are the most important properties of
package.json that you should configure to ensure that your builds are imported correctly:
[main]- This is the oldest way to define the entry-point to your library, so you should define this to ensure that even old Node environments and build tools can figure out your library. We suggest setting it to the UMD or CJS output, because they are the ones that the old environments are most likely to support.
[browser]- This is another field that you can use alongside
mainto specify the entry-point that should be used if building for the web. Tools like webpack can use this instead of
mainif you’ve set a web target in the build config.
module- This is a newer property that is supposed to specify the ESM entry-point to your library. It’s not actually standardized, but a lot of build tools will use it. When Webpack will process your library, it will first try to look for the
moduleproperty and prefer the ESM modules found there, instead of whatever you’re exposing in
main. ESM is the best format for the web because it can be analyzed and optimized very well.
[exports]- This is the newest field of all that effectively standardizes the syntax for specifying multiple output formats so that modern build tools and Node.js environments can pick the correct one. It’s a pretty powerful option that allows you to limit what can be imported and under what module identifiers, so a thorough investigation would be beneficial, but what matters most is that you can specify a separate CJS (
require) entry-point and a separate ESM (
::: tip 💡 TIP
Always make sure you configure the
browser fields in your
package.json correctly, so that your build outputs are used correctly and according to the final application’s build environment
Choosing Supported Target Environments
Also keep in mind that applications down the line, even if they have Babel in their build process, will usually exclude everything in
node_modules (your library) from being processed by Babel to speed things up. The expectation is that library maintainers will have already transpiled the library to be usable in all of the necessary environments.
Thus, you should also use Babel in your library build chain to transpile your ESNext source code into something more widely supported like ES2015. However, transpiling to an older ECMAScript version can come with a performance hit, so use your own discretion and evaluate which target version to build to.
::: tip 💡 Use Babel to transpile your library to an ECMAScript version that is widely supported. Evaluate which browsers and Node environments you want your library to be usable in.
Be Careful With
type field in
package.json is one that isn’t that new anymore but is still fairly difficult to wrap your head around. It not only changes how Node.js will read and understand your project, but also how build tools like webpack and vite will do so once your library is being used in an application.
Essentially by setting
module in your project’s
package.json you are telling Node and everyone else that it is a full-on ESM project - all of the
.js files there are expected to be in ESM not CJS and even the module import algorithm is a bit different. It’s supposed to be the property that moves your Node.js project from the older CJS module format to the now standardized and widespread ESM format.
The problem is that the JS ecosystem hasn’t figured out how to make publishing such libraries a painless experience. When we tried to move our @speckle/viewer package to this format, our frontend app running on webpack that was previously able to consume the ESM exports just fine was no longer able to handle some indirect dependencies correctly. We had to revert the changes. 😪
::: tip 💡TIP
Be careful with setting type to module in your library’s package.json, because this might break your previously working ESM builds once they’re consumed by a build tool down the line.
Suggestion: make sure you output an ESM build and make it accessible through the relevant package.json options, but maybe hold off on converting your project to a full-on ESM project through the type option unless you’re sure of the consequences.
Making Sure That The Build Is tree-shakable
To ensure that the application that depends on your library can properly “tree-shake” away all of the logic of your library that it doesn’t actually use or import, you need to either emit an ESM build or ensure that the CJS build isn’t emitted into a single large file and is organized in multiple modules the same way it’s organized in the source code.
If the library is only meant to be used in Node.js applications it’s not as important, because if your server has trouble handling that many dependencies (which is unlikely) you can just scale it up, but if the library is also supposed to support being bundled and run in the browser, you want to use every opportunity to reduce the size and overall footprint of the library to ensure that even browsers on slow devices won’t be affected by your library.
::: tip 💡 Modern JS libraries that support being used in the browser are expected to be optimized and lightweight, because you usually can’t control how powerful the devices running the website in the browser will be.
Here’s another aspect that people forget about and end up causing hard-to-debug errors in applications down the line once they import the library - making sure all third-party dependencies are externalized.
If you’re used to building web applications you probably don’t even think about this, because obviously you want to bundle all of the dependencies together so that they’re accessible and executable in the target environment (e.g., a browser). For example, if you have a Vue.js app that uses VueRouter for routing and Apollo Client for GraphQL operations, when you build it for the browser you most definitely want all of these dependencies (Vue.js, VueRouter and Apollo Client) to be bundled together with your own code.
lodash, for example, the bundled
lodash ESM or CJS output doesn’t include all of the code of the packages
lodash depends on. Those dependencies are left as
require calls and are instead imported from the
Node.js has a module resolution algorithm that can be fairly complicated, and your package manager will make many decisions about which packages to install, with which versions and also in which location (remember the
node_modules folder can be nested).
So, the main reason why you don’t want to concatenate your library’s third-party dependencies with the rest of your build output, is because this would replace all of the
require calls with parts of those dependencies, essentially skipping the node resolution algorithm and always forcing those specific versions to be used by your library. It is expected that Node.js project contributors are able to override indirect dependencies and/or update them, but this kind of approach of “baking in” the dependencies with the rest of your code prevents them from doing that. Moreover, it can also possibly cause that “baked in” dependency to be bundled in a second time (due to imports of it elsewhere) causing bloat if not also weird state issues that are triggered by multiple instances of the library being available at the same time.
::: tip 💡 TIP
If you’re building a library, always make sure
import calls to further dependencies of your library are left as-is and all of these dependencies are externalized, instead of being spliced into the built JS directly.
Emitting Types And Source-Maps
If you’re writing your library in TypeScript, you probably want your library to be usable in other TypeScript projects as well, so you should most definitely configure your build tool to also emit TypeScript typing information (usually in the format of
.d.ts files next to the JS build). To enable TypeScript projects to use this typing information you need to configure the
types field in your library's
package.json. This is like
main, but for typing information - it shows the TS compiler where to find typing information for the package.
::: tip 💡 If you’re writing your library in TypeScript, make sure you also publish types alongside your build.
Additionally, if you want to improve the debugging experience of developers consuming your library in other projects you might want to also emit source-maps. This will ensure that when your library is being debugged through or just opened through the
Sources tab in a browser’s dev tools, the library’s source code will be shown and stepped through, instead of the minified and uglified build artifact that is actually being invoked.
::: tip 💡 Source-maps will make it easier to read and debug through your library when it's used in another library or web application.
Even though the development process of a JS application and a JS library is somewhat similar, it’s clear that there are important nuances and differences to understand if you want the release to be a successful one. Not only would you want your build tool configured differently in each case, but you might even want to use different kinds of tools altogether.