How to organize frontend packages in monorepo, track changes across all projects, reuse shared libraries, and build packages with a modern build system
Building components and reusing them across different packages led me to conclude that it is necessary to organize the correct approach for the content of these projects in a single structure. Building tools should be the same, including testing environment, lint rules, and efficient resource allocation for component libraries.
I was looking for tools that could bring me efficient and effective ways to build robust, powerful combinations. And as a result, a formidable trio emerged. In this article, we will create several packages with all those tools.
Tools
Before we start, let’s examine what each of these tools does.
Lerna: managing JavaScript projects with multiple packages. It optimizes the workflow around managing multipackage repositories with git and npm.
Vite: build tool providing rapid hot module replacement, out-of-the-box ES Module support, extensive feature, and plugin support for React
Storybook: an open-source tool for developing and organizing UI components in isolation, which also serves as a platform for visual testing and creating interactive documentation.
Lerna Initial Setup
The first step will be to set up the Lerna project. Create a folder with lerna_vite_monorepo and inside that folder, run through the terminal npx lerna init — this will create an essential for the Lerna project. It generates two files — lerna.json, package.json — and empty folder packages.
lerna.json — this file enables Lerna to streamline your monorepo configuration, providing directives on how to link dependencies, locate packages, implement versioning strategies, and execute additional tasks.
Vite Initial Setup
Once the installation is complete, a packages folder will be available. Our next step involves creating several additional folders inside packages the folder:
- vite-common
- footer-components
- body-components
- footer-components
To create those projects, we have to run npm init vite with the project name. Choose React as a framework and Typescript as a variant. Those projects will use the same lint rules, build process, and React version.
This process in each package will generate a bunch of files and folders:
├── .eslintrc.cjs
├── .gitignore
├── index.html
├── package.json
├── public
│ └── vite.svg
├── src
│ ├── App.css
│ ├── App.tsx
│ ├── assets
│ │ └── react.svg
│ ├── index.css
│ ├── main.tsx
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
Storybook Initial Setup
Time to set up a Storybook for each of our packages. Go to one of the package folders and run there npx storybook@latest init for Storybook installation. For the question about eslint-plugin-storybook — input Y for installation. After that, the process of installing dependencies will be launched.
This will generate .storybook folder with configs and stories in src. Let’s remove the stories folder because we will build our own components.
Now, run the installation npx sb init –builder @storybook/builder-vite — it will help you build your stories with Vite for fast startup and HMR.
Assume that for each folder, we have the same configurations. If those installation has been accomplished, then you can run yarn storybook inside the package folder and run the Storybook.
Initial Configurations
The idea is to reuse common settings for all of our packages. Let’s remove some files that we don’t need in each repository. Ultimately, each folder you have should contain the following set of folders and files:
├── package.json
├── src
│ └── vite-env.d.ts
├── tsconfig.json
└── vite.config.ts
Now, let’s take all devDependencies and cut them from package.json in one of our package folders and put them all to devDependenices in the root package.json.
Run in root npx storybook@latest init and fix in main.js property:
stories: [
"../packages/*/src/**/*..mdx",
"../packages/*/src/**/*.stories.@(js|jsx|ts|tsx)"
],
And remove from the root in package.json two scripts:
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
Add components folder with index.tsx file to each package folder:
├── package.json
├── src
│ ├── components
│ │ └── index.tsx
│ ├── index.tsx
│ └── vite-env.d.ts
├── tsconfig.json
└── vite.config.ts
We can establish common configurations that apply to all packages. This includes settings for Vite, Storybook, Jest, Babel, and Prettier, which can be universally configured.
The root folder has to have the following files:
├── .eslintrc.cjs
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .prettierrc.json
├── .storybook
│ ├── main.ts
│ ├── preview-head.html
│ └── preview.ts
├── README.md
├── babel.config.json
├── jest.config.ts
├── lerna.json
├── package.json
├── packages
│ ├── vite-body
│ ├── vite-common
│ ├── vite-footer
│ └── vite-header
├── test.setup.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
We won’t be considering the settings of Babel, Jest, and Prettier in this instance.
Lerna Configuration
First, let’s examine the Lerna configuration file that helps manage our monorepo project with multiple packages.
First of all, “$schema” provides structure and validation for the Lerna configuration.
When “useWorkspaces” is true, Lerna will use yarn workspaces for better linkage and management of dependencies across packages. If false, Lerna manages interpackage dependencies in monorepo.
“packages” defines where Lerna can find the packages in the project.
“version” when set to “independent”, Lerna allows each package within the monorepo to have its own version number, providing flexibility in releasing updates for individual packages.
Common Vite Configuration
Now, let’s examine the necessary elements within the vite.config.ts file.
This file will export the common configs for Vite with extra plugins and libraries which we will reuse in each package. defineConfig serves as a utility function in Vite’s configuration file. While it doesn’t directly execute any logic or alter the passed configuration object, its primary role is to enhance type inference and facilitate autocompletion in specific code editors.
rollupOptions allows you to specify custom Rollup options. Rollup is the module bundler that Vite uses under the hood for its build process. By providing options directly to Rollup, developers can have more fine-grained control over the build process. The external option within rollupOptions is used to specify which modules should be treated as external dependencies.
In general, usage of the external option can help reduce the size of your bundle by excluding dependencies already present in the environment where your code will be run.
The output option with globals: { react: “React” } in Rollup’s configuration means that in your generated bundle, any import statements for react will be replaced with the global variable React. Essentially, it’s assuming that React is already present in the user’s environment and should be accessed as a global variable rather than included in the bundle.
The tsconfig.node.json file is used to specifically control how TypeScript transpiles with vite.config.ts file, ensuring it’s compatible with Node.js. Vite, which serves and builds frontend assets, runs in a Node.js environment. This separation is needed because the Vite configuration file may require different TypeScript settings than your frontend code, which is intended to run in a browser.
By including “types”: [“vite/client”] in tsconfig.json, is necessary because Vite provides some additional properties on the import.meta object that is not part of the standard JavaScript or TypeScript libraries, such as import.meta.env and import.meta.glob.
Common Storybook Configuration
The .storybook directory defines Storybook’s configuration, add-ons, and decorators. It’s essential for customizing and configuring how Storybook behaves.
├── main.ts
└── preview.ts
For the general configs, here are two files. Let’s check them all.
main.ts is the main configuration file for Storybook and allows you to control the behavior of Storybook. As you can see, we’re just exporting common configs, which we’re gonna reuse in each package.
preview.ts allows us to wrap stories with decorators, which we can use to provide context or set styles across our stories globally. We can also use this file to configure global parameters. Also, it will export that general configuration for package usage.
Root package.json
In a Lerna monorepo project, the package.json serves a similar role as in any other JavaScript or TypeScript project. However, some aspects are unique to monorepos.
Scripts will manage the monorepo. Running tests across all packages or building all packages. This package.json also include development dependencies that are shared across multiple packages in the monorepo, such as testing libraries or build tools. The private field is usually set to true in this package.json to prevent it from being accidentally published.
Scripts, of course, can be extended with other packages for testing, building, and so on, like:
"start:vite-footer": "lerna run --scope vite-footer storybook --stream",
Package Level Configuration
As far as we exported all configs from the root for reusing those configs, let’s apply them at our package level.
Vite configuration will use root vite configuration where we just import getBaseConfig function and provide there lib. This configuration is used to build our component package as a standalone library. It specifies our package’s entry point, library name, and output file name. With this configuration, Vite will generate a compiled file that exposes our component package under the specified library name, allowing it to be used in other projects or distributed separately.
For the .storybook, we use the same approach. We just import the commonConfigs.
And preview it as well.
For the last one from the .storybook folder, we need to add preview-head.html.
And the best part is that we have a pretty clean package.json without dependencies, we all use them for all packages from the root.
The only difference is vite-common, which is the dependency we’re using in the Footer component.
Components
By organizing our component packages in this manner, we can easily manage and publish each package independently while sharing common dependencies and infrastructure provided by our monorepo.
Let’s look at the folder src of the Footer component. The other components will be identical, but the configuration only makes the difference.
├── assets
│ └── flow.svg
├── components
│ ├── Footer
│ │ ├── Footer.stories.tsx
│ │ └── index.tsx
│ └── index.ts
├── index.ts
└── vite-env.d.ts
The vite-env.d.ts file in the src folder helps TypeScript understand and provide accurate type checking for Vite-related code in our project. It ensures that TypeScript can recognize and validate Vite-specific properties, functions, and features.
In the src folder, index.ts has:
export * from "./components";
And the component that consumes vite-common components look like this:
Here’s what stories looks like for the component:
We use four packages in this example, but the approach is the same. Once you create all the packages, you have to be able to build, run, and test them independently. Before all are in the root level, run yarn install then yarn build to build all packages, or build yarn build:vite-common and you can start using that package in your other packages.
Publish
To publish all the packages in our monorepo, we can use the npx lerna publish command. This command guides us through versioning and publishing each package based on the changes made.
lerna notice cli v6.6.2
lerna info versioning independent
lerna info Looking for changed packages since vite-body@1.0.0
? Select a new version for vite-body (currently 1.0.0) Major (2.0.0)
? Select a new version for vite-common (currently 2.0.0) Patch (2.0.1)
? Select a new version for vite-footer (currently 1.0.0) Minor (1.1.0)
? Select a new version for vite-header (currently 1.0.0)
Patch (1.0.1)
❯ Minor (1.1.0)
Major (2.0.0)
Prepatch (1.0.1-alpha.0)
Preminor (1.1.0-alpha.0)
Premajor (2.0.0-alpha.0)
Custom Prerelease
Custom Version
Lerna will ask us for each package version, and then you can publish it.
lerna info execute Skipping releases
lerna info git Pushing tags...
lerna info publish Publishing packages to npm...
lerna success All packages have already been published.
Conclusion
I was looking for a solid architecture solution for our frontend components organization in the company I am working for. For each project, we have a powerful, efficient development environment with general rules that help us become independent. This combination gives me streamlined dependency management, isolated component testing, and simplified publishing.
References
Repository: https://github.com/antonkalik/lerna-monorepo-boilerplate
Vite with Storybook: https://storybook.js.org/blog/storybook-for-vite
Lerna Monorepo with Vite and Storybook was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.