How we build, release and maintain frontend packages

Farhan CK

By Farhan CK

on June 11, 2024

Here at neeto, we build and manage a lot of products. 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 contains essential pure utility functions and forms the backbone of our development framework.

neeto-ui

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.

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

Build

In general we use Babel to transpile and Rollup to bundle with some exceptions.

Let's look at our standard build configuration.

1import peerDepsExternal from "rollup-plugin-peer-deps-external";
2import svgr from "@svgr/rollup";
3import babel from "@rollup/plugin-babel";
4import resolve from "@rollup/plugin-node-resolve";
5import alias from "@rollup/plugin-alias";
6import json from "@rollup/plugin-json";
7import commonjs from "@rollup/plugin-commonjs";
8import styles from "rollup-plugin-styles";
9import aliases from "./aliases";
10
11export default {
12  input: {
13    Component1: "src/components/Component1",
14    Component2: "src/components/Component2",
15    //... rest of the entry points
16  },
17  output: ["esm", "cjs"].map(format => ({
18    format,
19    sourcemap: true,
20    //... other options
21  })),
22  plugins: [
23    // To automatically externalize peerDependencies in a Rollup bundle.
24    peerDepsExternal(),
25    // Inline any svg files
26    svgr(),
27    // To integrate Rollup and Babel.
28    babel({
29      exclude: "node_modules/**",
30      babelHelpers: "runtime",
31    }),
32    // To use third party modules from node_modules
33    resolve({ extensions: [".js", ".jsx", ".svg"] }),
34    // To define aliases while bundling package.
35    alias({ entries: aliases }),
36    // To convert .json files to ES6 modules.
37    json(),
38    // To convert CommonJS modules to ES6.
39    commonjs(),
40    // Handle styles
41    styles({
42      minimize: true,
43      extensions: [".css", ".scss", ".min.css"],
44    }),
45  ],
46};

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 plugin made the configuration far easier. @svgr/rollup converts normal svg files to react components. 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 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 field in the package.json to specify which files can be imported by the host project. Below is a sample of our exports.

1"exports": {
2  "./react-utils": {
3    "import": "./react-utils/index.js",
4    "require": "./cjs/react-utils/index.js",
5    "types": "./react-utils.d.ts"
6  },
7  "./react-utils/*": {
8    "import": "./react-utils/*",
9    "require": "./cjs/react-utils/*",
10    "types": "./react-utils.d.ts"
11  },
12  "./utils": {
13    "import": "./utils/index.js",
14    "require": "./cjs/utils/index.js",
15    "types": "./utils.d.ts"
16  },
17  "./utils/*": {
18    "import": "./utils/*",
19    "require": "./cjs/utils/*",
20    "types": "./utils.d.ts"
21  },
22  "./initializers": {
23    "import": "./initializers/index.js",
24    "require": "./cjs/initializers/index.js",
25    "types": "./initializers.d.ts"
26  },
27  "./constants": {
28    "import": "./constants/index.js",
29    "require": "./cjs/constants/index.js",
30    "types": "./constants.d.ts"
31  }
32}
33

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

1import { useLocalStorage } from "neetocommons/react-utils";
1import 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.

1const defaultConfigurations = require("./defaultConfigurations");
2
3const alias = {
4  assets: "./src/assets",
5  neetocist: "@bigbinary/neeto-cist",
6  // others
7};
8
9module.exports = function (api) {
10  const config = defaultConfigurations(api);
11  config.sourceMaps = true;
12
13  config.plugins.push(
14    ["module-resolver", { root: ["./src"], alias }],
15    "inline-react-svg"
16  );
17
18  if (process.env.BABEL_MODE === "commonjs") {
19    config.overrides = [
20      {
21        presets: [["@babel/preset-env", { modules: "commonjs" }]],
22      },
23    ];
24  }
25  return config;
26};

When transpiling, we ensure aliases are correctly resolved, and if there are any SVG files, we inline them using the inline-react-svg plugin to make life easy for the host application. Below is our build script.

1"scripts": {
2  "build:pre": "del-cli dist",
3  "build:es": "babel  --extensions \".js,.jsx\" src --out-dir=dist",
4  "build:cjs": "BABEL_MODE=commonjs babel  --extensions \".js,.jsx\" src --out-dir=dist/cjs",
5  "build:post": "node ./.scripts/post-build.mjs",
6  "build": "yarn build:pre && yarn build:es && yarn build:cjs && yarn build:post",
7}

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.

1name: "Create and publish releases"
2on:
3  pull_request:
4    branches:
5      - main
6    types: [closed]
7jobs:
8  release:
9    name: "Create Release"
10    runs-on: ubuntu-latest
11    if: >-
12      ${{ github.event.pull_request.merged == true && (
13      contains(github.event.pull_request.labels.*.name, 'patch') ||
14      contains(github.event.pull_request.labels.*.name, 'minor') ||
15      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 to update the version.

1- name: Bump the patch version and create a git tag on release
2  if: ${{ contains(github.event.pull_request.labels.*.name, 'patch') }}
3  run: yarn version --patch --no-git-tag-version
4
5- name: Bump the minor version and create a git tag on release
6  if: ${{ contains(github.event.pull_request.labels.*.name, 'minor') }}
7  run: yarn version --minor --no-git-tag-version
8
9- name: Bump the major version and create a git tag on release
10  if: ${{ contains(github.event.pull_request.labels.*.name, 'major') }}
11  run: yarn version --major --no-git-tag-version

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

1- name: Extract changelog
2  id: CHANGELOG
3  run: |
4    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()))')
5    echo "CHANGELOG=${content}" >> $GITHUB_ENV
6  shell: bash
7
8- name: Update Changelog
9  continue-on-error: true
10  uses: stefanzweifel/changelog-updater-action@v1
11  with:
12    latest-version: ${{ steps.package-version.outputs.version }}
13    release-notes: ${{ fromJson(env.CHANGELOG) }}

Finally, we publish the package to NPM.

1- name: Publish the package on NPM
2  uses: JS-DevTools/npm-publish
3  with:
4    access: "public"
5    token: ${{ secrets.NPM_TOKEN }}

Maintainenance

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 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 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.

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

Stay up to date with our blogs. Sign up for our newsletter.

We write about Ruby on Rails, ReactJS, React Native, remote work,open source, engineering & design.