---
title: "How we build, release and maintain frontend packages"
description: "How we build, release and maintain frontend packages"
canonical_url: "https://www.bigbinary.com/blog/build-release-frontend-packages"
markdown_url: "https://www.bigbinary.com/blog/build-release-frontend-packages.md"
---

# How we build, release and maintain frontend packages

How we build, release and maintain frontend packages

- Author: Farhan CK
- Published: June 11, 2024
- Categories: JavaScript, ReactJS, npm

Here at [neeto](https://www.neeto.com/), we build and manage a
[lot of products](https://blog.neeto.com/p/neeto-products-and-people). Each
product has its team. However, ensuring consistent design and functionalities
across these products poses a significant challenge. To aid our product teams in
focusing on their core business logic, we've organized common functionalities
into separate packages.

In this blog, we'll look into how we build, release, and maintain these packages
to support our product development cycles. Let's take a look at some of these
key packages.

### neeto-cist

[neeto-cist](https://github.com/bigbinary/neeto-cist) contains essential pure
utility functions and forms the backbone of our development framework.

### neeto-ui

[neeto-ui](https://github.com/bigbinary/neeto-ui) is the foundational design
structure for our Neeto products. It contains basic-level components like
Buttons, Input fields, etc.

### neeto-molecules

Built on top of `neeto-ui`, the `neeto-molecules` package houses reusable UI
elements like a login page, settings page, sidebars etc. It simplifies the
creation of consistent user experiences across Neeto products.

### neeto-commons

`neeto-commons` store crucial elements such as initializers, constants, hooks
and configurations shared across our entire product range. We wrote in great
detail about the
[challenges we faced while building neeto-commons](https://www.bigbinary.com/blog/neeto-commons-frontend).

Let's look at how we build, release, and maintain these packages.

## Build

In general we use [Babel](https://babeljs.io/) to transpile and
[Rollup](https://rollupjs.org) to bundle with some exceptions.

Let's look at our standard build configuration.

```js
import peerDepsExternal from "rollup-plugin-peer-deps-external";
import svgr from "@svgr/rollup";
import babel from "@rollup/plugin-babel";
import resolve from "@rollup/plugin-node-resolve";
import alias from "@rollup/plugin-alias";
import json from "@rollup/plugin-json";
import commonjs from "@rollup/plugin-commonjs";
import styles from "rollup-plugin-styles";
import aliases from "./aliases";

export default {
  input: {
    Component1: "src/components/Component1",
    Component2: "src/components/Component2",
    //... rest of the entry points
  },
  output: ["esm", "cjs"].map(format => ({
    format,
    sourcemap: true,
    //... other options
  })),
  plugins: [
    // To automatically externalize peerDependencies in a Rollup bundle.
    peerDepsExternal(),
    // Inline any svg files
    svgr(),
    // To integrate Rollup and Babel.
    babel({
      exclude: "node_modules/**",
      babelHelpers: "runtime",
    }),
    // To use third party modules from node_modules
    resolve({ extensions: [".js", ".jsx", ".svg"] }),
    // To define aliases while bundling package.
    alias({ entries: aliases }),
    // To convert .json files to ES6 modules.
    json(),
    // To convert CommonJS modules to ES6.
    commonjs(),
    // Handle styles
    styles({
      minimize: true,
      extensions: [".css", ".scss", ".min.css"],
    }),
  ],
};
```

When dealing with multiple entry points, passing an array of `input` is a common
mistake people make. The problem with an `input` array is that it will be
duplicated if there is shared code. So, the best approach here is to pass a
key-value object. This way, Rollup will create separate files for shared code
and reuse them everywhere.

Looking at the `output`, notice that we built it for CommonJS and ECMAScript.
Even though we don't have any Node.js projects (we are mainly a Rails company),
we still need the CommonJS format to run Jest tests and some scripts for build
and automation purposes.

When using Babel to transpile and Rollup to bundle, we ran into some
configuration pain points.
[`@rollup/plugin-babel`](https://www.npmjs.com/package/@rollup/plugin-babel)
plugin made the configuration far easier.
[`@svgr/rollup`](https://www.npmjs.com/package/@svgr/rollup) converts normal svg
files to react components.
[`rollup-plugin-peer-deps-external`](https://www.npmjs.com/package/rollup-plugin-peer-deps-external)
automatically externalizes peer dependencies, keeping the bundle lean. Even
though we don't have any CommonJS modules, it's better to add
[`@rollup/plugin-commonjs`](https://www.npmjs.com/package/@rollup/plugin-commonjs)
so that if there is any deep in dependency rabbit hole.

Above is a simplified version of the Rollup configuration we use on
`neeto-cist`, `neeto-ui` and `neeto-molecules`. We took a much simpler approach
when building `neeto-commons`. The reason for abandoning bundling for
`neeto-commons` is that within this package, we have a variety of commonly used
elements, such as components, hooks, initializers, constants, utils, etc., each
with varying sizes and dependency requirements. If the host project only wants
to use a few simple util functions, we don't want them to be served with the
entire bundle and need to install lots of dependencies.

Instead of serving everything from the root `index.js`, we use
[`exports`](https://webpack.js.org/guides/package-exports/) field in the
`package.json` to specify which files can be imported by the host project. Below
is a sample of our exports.

```json
"exports": {
  "./react-utils": {
    "import": "./react-utils/index.js",
    "require": "./cjs/react-utils/index.js",
    "types": "./react-utils.d.ts"
  },
  "./react-utils/*": {
    "import": "./react-utils/*",
    "require": "./cjs/react-utils/*",
    "types": "./react-utils.d.ts"
  },
  "./utils": {
    "import": "./utils/index.js",
    "require": "./cjs/utils/index.js",
    "types": "./utils.d.ts"
  },
  "./utils/*": {
    "import": "./utils/*",
    "require": "./cjs/utils/*",
    "types": "./utils.d.ts"
  },
  "./initializers": {
    "import": "./initializers/index.js",
    "require": "./cjs/initializers/index.js",
    "types": "./initializers.d.ts"
  },
  "./constants": {
    "import": "./constants/index.js",
    "require": "./cjs/constants/index.js",
    "types": "./constants.d.ts"
  }
}

```

We export, for example, `./react-utils` and `./react-utils/*` because we want to
support both import styles below.

```js
import { useLocalStorage } from "neetocommons/react-utils";
```

```js
import useLocalStorage from "neetocommons/react-utils/useLocalStorage";
```

We initially employed the first import style, but as we expanded, we recognized
the necessity of supporting a more concise approach in smaller projects. This
second method involves importing solely the target file without additional
dependencies, significantly improving tree-shaking capabilities.

We also ensure we build for `esm` and `cjs`. Below is our simplified Babel
config.

```js
const defaultConfigurations = require("./defaultConfigurations");

const alias = {
  assets: "./src/assets",
  neetocist: "@bigbinary/neeto-cist",
  // others
};

module.exports = function (api) {
  const config = defaultConfigurations(api);
  config.sourceMaps = true;

  config.plugins.push(
    ["module-resolver", { root: ["./src"], alias }],
    "inline-react-svg"
  );

  if (process.env.BABEL_MODE === "commonjs") {
    config.overrides = [
      {
        presets: [["@babel/preset-env", { modules: "commonjs" }]],
      },
    ];
  }
  return config;
};
```

When transpiling, we ensure aliases are correctly resolved, and if there are any
SVG files, we inline them using the
[`inline-react-svg`](https://www.npmjs.com/package/babel-plugin-inline-react-svg)
plugin to make life easy for the host application. Below is our build script.

```json
"scripts": {
  "build:pre": "del-cli dist",
  "build:es": "babel  --extensions \".js,.jsx\" src --out-dir=dist",
  "build:cjs": "BABEL_MODE=commonjs babel  --extensions \".js,.jsx\" src --out-dir=dist/cjs",
  "build:post": "node ./.scripts/post-build.mjs",
  "build": "yarn build:pre && yarn build:es && yarn build:cjs && yarn build:post",
}
```

## Release

We did not want to create a release every time we merged a PR, and we also
didn't want to manually release packages every time. Instead, we rely on GitHub
Labels while running Github Actions to do the release.

We created three labels specifically for this purpose: `patch`, `minor` and
`major`. As the names suggest, these labels help us create `patch`, `minor` and
`major` versions. When we create a PR and want to do a release when merging,
attach any of these labels, and GitHub Action will create releases accordingly.

```yaml
name: "Create and publish releases"
on:
  pull_request:
    branches:
      - main
    types: [closed]
jobs:
  release:
    name: "Create Release"
    runs-on: ubuntu-latest
    if: >-
      ${{ github.event.pull_request.merged == true && (
      contains(github.event.pull_request.labels.*.name, 'patch') ||
      contains(github.event.pull_request.labels.*.name, 'minor') ||
      contains(github.event.pull_request.labels.*.name, 'major') ) }}
```

As evident from the code above, we trigger the `Create and publish releases`
GitHub Action only if any of the three aforementioned labels are present. Once
that condition is met, we proceed to use
[`yarn version`](https://classic.yarnpkg.com/lang/en/docs/cli/version/) to
update the version.

```yaml
- name: Bump the patch version and create a git tag on release
  if: ${{ contains(github.event.pull_request.labels.*.name, 'patch') }}
  run: yarn version --patch --no-git-tag-version

- name: Bump the minor version and create a git tag on release
  if: ${{ contains(github.event.pull_request.labels.*.name, 'minor') }}
  run: yarn version --minor --no-git-tag-version

- name: Bump the major version and create a git tag on release
  if: ${{ contains(github.event.pull_request.labels.*.name, 'major') }}
  run: yarn version --major --no-git-tag-version
```

Then, we extract changelogs from PR's title and description.

```yaml
- name: Extract changelog
  id: CHANGELOG
  run: |
    content=$(echo '${{ steps.PR.outputs.pr_body }}' | python3 -c 'import json; import sys; print(json.dumps(sys.stdin.read().partition("**Description**")[2].partition("**Checklist**")[0].strip()))')
    echo "CHANGELOG=${content}" >> $GITHUB_ENV
  shell: bash

- name: Update Changelog
  continue-on-error: true
  uses: stefanzweifel/changelog-updater-action@v1
  with:
    latest-version: ${{ steps.package-version.outputs.version }}
    release-notes: ${{ fromJson(env.CHANGELOG) }}
```

Finally, we publish the package to NPM.

```yaml
- name: Publish the package on NPM
  uses: JS-DevTools/npm-publish
  with:
    access: "public"
    token: ${{ secrets.NPM_TOKEN }}
```

## Maintenance

Now that the release is done, we need to propagate changes to our products. For
example, we may have extracted an existing functionality from one of the
packages. Here is an example of
[how we standardized keyboard shortcuts in neeto](https://www.bigbinary.com/blog/how-we-standardized-keyboard-shortcuts)
and extracted out into a package. Now, the product team needs to remove that
functionality and use it from the package. Reaching out to each team and asking
them to replace it can be tedious, especially considering their priorities. So
we came up with a plan to use custom
[ESLint rules](https://eslint.org/docs/latest/extend/custom-rules) to enforce
them, and we built `eslint-plugin-neeto`.

Let's look at a few examples of how we use `eslint-plugin-neeto` to enforce
changes on the product team.

There are some third-party packages we decided not to use when we found better
alternatives, and they even included our own. For example, `bootstrap`,
`moment.js`, `@bigbinary/neeto-utils`, etc. To enforce this, we create ESLint
rules called `no-blacklisted-imports`. This rule throws a lint error if
developers attempt to commit changes that include these blacklisted imports.

Another example is that we moved higher-level constants, commonly utilized
across various products, to `neeto-commons`. To enforce this, we created an
ESLint rule called `use-common-constants`. This rule detects any local imports
and recommends importing from `neeto-commons` instead.

Here is another great blog post in which we explained the challenges we faced
while
[adding translations and enforcing them using ESLint and Babel plugins](https://www.bigbinary.com/blog/react-localization).

These are just a few of the many ESLint and Babel plugins we created.

## Links

- [Human page](https://www.bigbinary.com/blog/build-release-frontend-packages)
