Component Library With Lerna Monorepo, Vite, and Storybook
Learn how to organize frontend packages in monorepo, track changes across all projects, reuse shared libraries, and build packages with a modern build system.
Join the DZone community and get the full member experience.
Join For FreeBuilding 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. 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: Manages 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.
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"useWorkspaces": true,
"packages": ["packages/*"],
"version": "independent"
}
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.
import path from "path";
import { defineConfig } from "vite";
import pluginReact from "@vitejs/plugin-react";
const isExternal = (id: string) => !id.startsWith(".") && !path.isAbsolute(id);
export const getBaseConfig = ({ plugins = [], lib }) =>
defineConfig({
plugins: [pluginReact(), ...plugins],
build: {
lib,
rollupOptions: {
external: isExternal,
output: {
globals: {
react: "React",
},
},
},
},
});
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.
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
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.
{
"compilerOptions": {
// ...
"types": ["vite/client", "jest", "@testing-library/jest-dom"],
// ...
},
"references": [{ "path": "./tsconfig.node.json" }]
}
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.
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
addons: [
{
name: "@storybook/preset-scss",
options: {
cssLoaderOptions: {
importLoaders: 1,
modules: {
mode: "local",
auto: true,
localIdentName: "[name]__[local]___[hash:base64:5]",
exportGlobals: true,
},
},
},
},
{
name: "@storybook/addon-styling",
options: {
postCss: {
implementation: require("postcss"),
},
},
},
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"storybook-addon-mock",
],
framework: {
name: "@storybook/react-vite",
options: {},
},
docs: {
autodocs: "tag",
},
};
export default config;
File 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.
import type { Preview } from "@storybook/react";
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
options: {
storySort: (a, b) => {
return a.title === b.title
? 0
: a.id.localeCompare(b.id, { numeric: true });
},
},
layout: "fullscreen",
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};
export default preview;
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.
{
"name": "root",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"start:vite-common": "lerna run --scope vite-common storybook --stream",
"build:vite-common": "lerna run --scope vite-common build --stream",
"test:vite-common": "lerna run --scope vite-common test --stream",
"start:vite-body": "lerna run --scope vite-body storybook --stream",
"build": "lerna run build --stream",
"test": "NODE_ENV=test jest --coverage"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/core": "^7.22.1",
"@babel/preset-env": "^7.22.2",
"@babel/preset-react": "^7.22.3",
"@babel/preset-typescript": "^7.21.5",
"@storybook/addon-actions": "^7.0.18",
"@storybook/addon-essentials": "^7.0.18",
"@storybook/addon-interactions": "^7.0.18",
"@storybook/addon-links": "^7.0.18",
"@storybook/addon-styling": "^1.0.8",
"@storybook/blocks": "^7.0.18",
"@storybook/builder-vite": "^7.0.18",
"@storybook/preset-scss": "^1.0.3",
"@storybook/react": "^7.0.18",
"@storybook/react-vite": "^7.0.18",
"@storybook/testing-library": "^0.1.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/jest": "^29.5.1",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react": "^4.0.0",
"babel-jest": "^29.5.0",
"babel-loader": "^8.3.0",
"eslint": "^8.41.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"eslint-plugin-storybook": "^0.6.12",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"lerna": "^6.5.1",
"path": "^0.12.7",
"prettier": "^2.8.8",
"prop-types": "^15.8.1",
"sass": "^1.62.1",
"storybook": "^7.0.18",
"storybook-addon-mock": "^4.0.0",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"typescript": "^5.0.2",
"vite": "^4.3.2"
}
}
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.
import * as path from "path";
import { getBaseConfig } from "../../vite.config";
export default getBaseConfig({
lib: {
entry: path.resolve(__dirname, "src/index.ts"),
name: "ViteFooter",
fileName: "vite-footer",
},
});
For the .storybook
, we use the same approach. We just import the commonConfigs
.
import commonConfigs from "../../../.storybook/main";
const config = {
...commonConfigs,
stories: ["../src/**/*..mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
};
export default config;
And preview it as well.
import preview from "../../../.storybook/preview";
export default preview;
For the last one from the .storybook
folder, we need to add preview-head.html
.
<script>
window.global = window;
</script>
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.
{
"name": "vite-footer",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"vite-common": "^2.0.0"
}
}
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.
/// <reference types="vite/client" />
In the src
folder, index.ts
has:
export * from "./components";
And the component that consumes vite-common
components look like this:
import { Button, Links } from "vite-common";
export interface FooterProps {
links: {
label: string;
href: string;
}[];
}
export const Footer = ({ links }: FooterProps) => {
return (
<footer>
<Links links={links} />
<Button label="Click Button" backgroundColor="green" />
</footer>
);
};
export default Footer;
Here’s what stories
looks like for the component:
import { StoryFn, Meta } from "@storybook/react";
import { Footer } from ".";
export default {
title: "Example/Footer",
component: Footer,
parameters: {
layout: "fullscreen",
},
} as Meta<typeof Footer>;
const mockedLinks = [
{ label: "Home", href: "/" },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
];
const Template: StoryFn<typeof Footer> = (args) => <Footer {...args} />;
export const FooterWithLinks = Template.bind({});
FooterWithLinks.args = {
links: mockedLinks,
};
export const FooterWithOneLink = Template.bind({});
FooterWithOneLink.args = {
links: [mockedLinks[0]],
};
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 front-end 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
Published at DZone with permission of Anton Kalik. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments