Building a npm library with Web Components using Lerna, Rollup and Jest

How to setup a npm package project with Web Components using Lerna, Rollup, TypeScript and Jest.

Jens-Christian Bjerkek
10 min readJan 11, 2021

I’m no expert, so I suggest you don’t trust everything I write below. That said, this worked on my machine 🙈 and is what I used to figure out how to setup my own npm package with Web Components using Lerna, Rollup, TypeScript and Jest. If you find something I should change, please head over to the discussion on GitHub and help me make both the article and the example repo better.

I won’t dwell too much on my choice of frameworks, I guess you landed on this guide because you googled for some of them.

Web Components

My initial goal was to recreate some react components from the design system we use at work as web components. My two cents here is that web components, using shadow DOM, makes it easy to encapsulate the components and fully control the look and feel and they are framework agnostic.

Lerna

I wanted to publish separate packages on npm and needed a framework for making it easier to maintain a JavaScript project with multiple packages with dependencies and independently versioned packages. Lerna documentation.

Rollup

The old saying “Rollup for libraries, Webpack for apps” might no longer be entirely correct. The functionality gap between different bundlers has been narrowing over the years and new bundlers like Parcel and Snowpack gets traction. Anyway, I just wanted to learn more about Rollup.

Jest

Easy to get started and easy to use.

1. Init the monorepo with Lerna

Let’s start by installing Lerna as a dev dependency of your project.

$ mkdir my-repo && cd $_
$ npm install --g lerna
$ lerna init --independent

Notice the use of the --independent flag, allowing us to increment package versions independently of each other. Each time you publish, you will get a prompt for each package that has changed to specify if it’s a patch, minor, major or custom change.

You should now have some files and folders generated inside /my-repo:

my-repo
|-packages/
|-lerna.json
|-package.json

With a standard package.json

{
“name”: “root”,
“private”: true,
“devDependencies”: {
“lerna”: “^3.22.1”
}
}

… and a lerna.json.

{
“packages”: [
“packages/*”
],
“version”: “independent”
}

Nothing much really, but it’s all you need before starting adding your components.

2. Adding your web components

This guide isn’t about web components so I’m just going to use some simple code here. A classic use of ComponentA and ComponentB.

Note that Lerna do offer a command for creating new packages, but in this case I wanted to keep it simple and just created the files manually and skipping the boilerplate files created by lerna create <name>.

Add the following files:

my-repo
|-packages/
|-component-a/
|-component-a.ts
|-index.ts
|-package.json
|-template.html
|component-b/
|-component-b.ts
|-index.ts
|-package.json
|-template.html
|-lerna.json
|-package.json

We are adding two components here, but they are exactly the same. The only difference is the name.

my-repo/packages/component-a/component-a.tsimport HTMLTemplate from ‘./template.html’const template = document.createElement(‘template’)
template.innerHTML = HTMLTemplate
export const tagName = 'component-a'export class ComponentA extends HTMLElement {
#shadowRoot: ShadowRoot
constructor () {
super()
this.#shadowRoot = this.attachShadow({ mode: ‘open’ })
this.#shadowRoot.appendChild(template.content.cloneNode(true))
}
}
window.customElements.define(‘component-a’, ComponentA)

Writing HTML and CSS is easier when using HTML files, so we put all necessary markup and styling in a separate file.

my-repo/packages/component-a/template.html<style>
.container {
padding: 8px;
}
</style>
<div class=”container”>
It works
</div>

We also need an entry point for our component and we need to define it.

my-repo/packages/component-a/index.tsimport { ComponentA, tagName } from ‘./component-a’if (!window.customElements.get(tagName)) {
window.customElements.define(tagName, ComponentA)
}

And finally, adding a package.json.

my-repo/packages/component-a/package.json{
“name”: “@my-repo/component-a”,
“version”: “0.0.1”
}

That’s it for ComponentA. Next step is repeating this for ComponentB.

3. Rollup

We need some tool to bundle our code and we need to add support for TypeScript. The great thing with Lerna is, even though we eventually want to bundle each package, we can add our dependencies in the Lerna root folder my-repo/ together with a shared bundle configuration. You can read up on how Lerna works here.

First we need to add Rollup to our project …

$ npm i -D rollup @rollup/plugin-node-resolve 

… before we create our Rollup config.

my-repo/rollup.config.jsimport resolve from '@rollup/plugin-node-resolve'const PACKAGE_ROOT_PATH = process.cwd()
const { LERNA_PACKAGE_NAME } = process.env
export default {
input: `${PACKAGE_ROOT_PATH}/index.ts`,
output: [
{
file: 'dist/bundle.cjs.js',
format: 'cjs'
},
{
file: 'dist/bundle.esm.js',
format: 'esm'
},
{
name: LERNA_PACKAGE_NAME,
file: 'dist/bundle.umd.js',
format: 'umd'
}
],
plugins: [
resolve()
]
}

As you might notice we are adding 3 different formats in output, CommonJS, ES Modules and UMD. I won’t go into details about why, but you can read more about this in the Rollup documentation.

Since we are using static html files we need a plugin to converts them to modules. Let’s add i to our project…

$ npm i -D rollup-plugin-string

... and update the rollup config.

my-repo/rollup.config.jsimport resolve from '@rollup/plugin-node-resolve'
import { string } from 'rollup-plugin-string'
const PACKAGE_ROOT_PATH = process.cwd()
const { LERNA_PACKAGE_NAME } = process.env
export default {
input: `${PACKAGE_ROOT_PATH}/index.ts`,
output: [
{
file: 'dist/bundle.cjs.js',
format: 'cjs'
},
{
file: 'dist/bundle.esm.js',
format: 'esm'
},
{
name: LERNA_PACKAGE_NAME,
file: 'dist/bundle.umd.js',
format: 'umd'
}
],
plugins: [
resolve(),
string({
include: '**/*.html'
})

]
}

4. TypeScript

Next step is to add TypeScript and necessary Rollup plugins

$ npm i typescript tslib
$ npm i -D rollup @rollup/plugin-typescript

…and then update the Rollup config again.

my-repo/rollup.config.jsimport resolve from '@rollup/plugin-node-resolve'
import typescript from '@rollup/plugin-typescript'
const PACKAGE_ROOT_PATH = process.cwd()
const { LERNA_PACKAGE_NAME, LERNA_ROOT_PATH } = process.env
export default {
input: `${PACKAGE_ROOT_PATH}/index.ts`,
output: [
{
file: 'dist/bundle.cjs.js',
format: 'cjs'
},
{
file: 'dist/bundle.esm.js',
format: 'esm'
},
{
name: LERNA_PACKAGE_NAME,
file: 'dist/bundle.umd.js',
format: 'umd'
}
],
plugins: [
resolve(),
typescript({
tsconfig: `${LERNA_ROOT_PATH}/tsconfig.json`
})

]
}

Lastly we need to create a new file for the TypeScript configuration.

my-repo/tsconfig.json{
“compilerOptions”: {
“target”: “es2018”
},
“include”: [“**/*.ts”]
}

After adding TypeScript we get an issue with our static html-files. I struggled a lot with this and ended up with just adding a definition file inside each package.

my-repo
|-packages/
|-component-a/
|-html.d.ts
|-index.ts
|-package.json
|-template.html
|component-b/
|-html.d.ts
|-index.ts
|-package.json
|-template.html
|-lerna.json
|-package.json

Looking like this:

declare module ‘*.html’ {
const value: string
/* @ts-ignore */
export default value
}

Not ideal and I guess some of you know how to solve this differently and are screaming at the screen right now. In stead, please let me know in the comments or at the discussion at GitHub.

5. Babel

We’re getting there, but our packages also needs some transpiling. Let’s add Babel and some presets to the project and a couple of babel plugins for transpiling imports and classes used in our my-repo/packages/component-a/index.ts.

$ npm i -D @babel/core @babel/preset-env @babel/preset-typescript
$ npm i -D @rollup/plugin-babel
$ npm i -D @babel/plugin-proposal-class-properties @babel/plugin-transform-runtime
$ npm i @babel/runtime

Let’s start by adding the babel configuration.

my-repo/babel.config.jsmodule.exports = api => {
const presets = [
'@babel/preset-typescript',
[
'@babel/preset-env', {
modules: false
}
]
]
const plugins = [
'@babel/plugin-transform-runtime',
'@babel/plugin-proposal-class-properties'
]
return {
presets,
plugins
}
}

When setting up preset-env above it’s important to note we are setting modules: false. We are doing this making sure Babel don’t transpile the code and leaves this part to Rollup. If we were to i.e. set modules: cjs we would loose tree shaking. You can read more about the possible settings here.

Last step is to add babel to our rollup config. Change rollup.config.js as follows.

my-repo/rollup.config.jsimport resolve from '@rollup/plugin-node-resolve'
import typescript from '@rollup/plugin-typescript'
import babel from '@rollup/plugin-babel'
const PACKAGE_ROOT_PATH = process.cwd()
const { LERNA_PACKAGE_NAME } = process.env
export default {
input: `${PACKAGE_ROOT_PATH}/index.ts`,
external: [/@babel\/runtime/],
output: [
{
file: 'dist/bundle.cjs.js',
format: 'cjs'
},
{
file: 'dist/bundle.esm.js',
format: 'esm'
},
{
name: LERNA_PACKAGE_NAME,
file: 'dist/bundle.umd.js',
format: 'umd'
}
],
plugins: [
resolve(),
typescript({
tsconfig: `${LERNA_ROOT_PATH}/tsconfig.json`
}),
babel({
exclude: 'node_modules/**',
rootMode: 'upward',
babelHelpers: 'runtime'
})
]
}

When adding the babel plugin we first make sure that files inside /node_modules are excluded from transpiling. Second, we need to tell the babel that the context is inside /packages/[package-name] and to be able to find our babel config file babel needs to travers upwards. Keep in mind that when running lerna exec --rollup later on we will be in the context of the package, not the root folder.

Lastly we are setting external: [/@babel\/runtime/] and babelHelpers: runtimewhich is recomended when using the @rollup/plugin-babel:

'runtime' - you should use this especially when building libraries with Rollup. It has to be used in combination with @babel/plugin-transform-runtime and you should also specify @babel/runtime as dependency of your package.

6. Let’s get ready to bundle

Next and final step before testing the bundler is adding some scripts to run. This is another place where Lerna shines, with the exec command. When the number of components grow I guess you also need to bundle them one at a time, but for now we just set up our script to bundle every package. Add the following to package.json.

package.json{
“name”: “root”,
“private”: true,
"scripts": {
"build": "lerna exec -- rollup -c ../../rollup.config.js"
},

“devDependencies”: {
...
},
"dependencies": {
...
}
}

The command execrun an arbitrary command in each package, in this case rollup. Here we also need to tell rollup where to find the configuration file. Keep in mind we are in the context of each package, hence the ../../.

Here we go 🤞

$ npm run build

Hopefully you will end up with a /dist folder within each package with 3 files, bundle.esm.js bundle.cjs and bundle.amd.js .

One final thing before we move on. Since the goal here is to create and publish a npm package, we generates bundles for both CommonJS, ES modules, and UMD. We need to update the package.json in our different packages accordingly.

my-repo/packages/component-a/package.json{
“name”: “@my-repo/a”,
“version”: “0.0.1”,
"main": "dist/bundle.cjs.js",
"module": "dist/bundle.esm.js",
}

7. Minifying

Our library is now bundled and transpiled, but it’s currently bigger than it needs to be. Let’s minify our code using terser via the rollup-plugin-terser.

$ npm i -D rollup-plugin-terser

And add the plugin to the rollup config:

my-repo/rollup.config.jsimport resolve from '@rollup/plugin-node-resolve'
import typescript from '@rollup/plugin-typescript'
import babel from '@rollup/plugin-babel'
import { terser } from 'rollup-plugin-terser'
const production = !process.env.ROLLUP_WATCHconst PACKAGE_ROOT_PATH = process.cwd()
const { LERNA_PACKAGE_NAME } = process.env
export default {
input: `${PACKAGE_ROOT_PATH}/index.ts`,
output: [
{
file: 'dist/bundle.cjs.js',
format: 'cjs'
},
{
file: 'dist/bundle.esm.js',
format: 'esm'
},
{
name: LERNA_PACKAGE_NAME,
file: 'dist/bundle.umd.js',
format: 'umd'
}
],
plugins: [
resolve(),
typescript({
tsconfig: `${LERNA_ROOT_PATH}/tsconfig.json`
}),
babel({
exclude: 'node_modules/**',
rootMode: 'upward',
babelHelpers: 'runtime'
}),
production && terser()

]
}

Note that we only want to minify our code when we bundle for production.

8. Testing with Jest

Testing Web Components using Jest haven’t been the easiest thing to do. Both because of lack of support for shadow DOM and for custom elements in JSDOM. But luckily for us, basic support was added in JSDOM 16.2 and is available in Jest 26.5 and above.

First of, let’s add Jest to our project …

$ npm i -D jest @types/jest

… and Testing Library, which is a very light-weight solution for testing DOM nodes.

$ npm i -D jest @testing-library/dom

Next step is to add a script to our package.json …

package.json{
“name”: “root”,
“private”: true,
"scripts": {
"build": "lerna exec -- rollup -c ../../rollup.config.js",
"test": "jest"

},
“devDependencies”: {
...
},
"dependencies": {
...
}
}

… before we write up some simple tests.

my-repo/packages/component-a/component-a.test.jsimport { within } from '@testing-library/dom'
import { ComponentA, tagName } from './component-a'
describe('ComponenetA', () => { it('Renders ComponentA', () => {
window.customElements.define(tagName, ComponentA)
let element = document.createElement(tagName)
document.body.appendChild(element)
const { getByTestId } = within(element.shadowRoot)
element = getByTestId('componentA')
expect(element).toBeTruthy
})
})

We are using getByTestId so we also need to update the html template.

my-repo/packages/componen-a/template.html<style>
.container {
padding: 8px;
}
</style>
<div class=”container” data-testid=”componentA”>
It works
</div>

Jest won’t be able to load our static html files we use as templates in our packages. Normally we would just add mocks to our project, but in this case we actually need the content. The solution is adding a transformer for html files in the Jest configuration. Add the following file to the root of your project …

fileTransformer.jsmodule.exports = {
process: (content, _path) => {
// escape newlines
const json = JSON.stringify(content)
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
return `module.exports = ${json};`
}
}

… and configure Jest to use the transformer in the package.json.

package.json{
“name”: “root”,
“private”: true,
"scripts": {
"build": "lerna exec -- rollup -c ../../rollup.config.js",
"test": "jest"
},
“devDependencies”: {
...
},
"dependencies": {
...
},
"jest": {
"transform": {
"\\.[jt]s?$": "babel-jest",
"\\.html$": "<rootDir>/fileTransformer.js"
}
}

}

Note that when adding additional code transformers, this will overwrite the default jest configuration and babel-jest is no longer automatically loaded and needs to be explicitly defined.

We also need to update our Babel config, adding separate config for testing. Replace all of it with the following:

my-repo/babel.config.jsmodule.exports = api => {
api.cache(true)
const presets = [
'@babel/preset-typescript'
]
const plugins = [
'@babel/plugin-transform-runtime'
]
const env = {
production: {
presets: [
'@babel/preset-env', {
targets: ['last 2 versions', 'ie 11'],
modules: false
}
]
},
test: {
presets: [
'@babel/preset-env'
],
plugins: [
'@babel/plugin-proposal-class-properties'
]
}
}
return {
presets,
plugins,
env
}
}

All there is left to do is running our tests.

$ npm run test

There are more work to be done here before our library is state of the art, but that’s not the goal of this guide. To learn more about setting up modern build workflow I highly suggest checking out CodeRealm and their series Build a Modern JS Project, which was a huge inspiration for this article. It wont be hard to find similarities.

--

--