How to manage multiple Front-End projects with a monorepo

👉 This blog post is crafted for individuals with solid web development skills. If you're already familiar with setting up web projects but want to enhance your skills further, we recommend reading our previous blog post, How to set up a Front-End project with Vite, React, and TypeScript, which covers foundational concepts that will reinforce your understanding of the content discussed in this blog post.

Imagine having an organization with multiple projects. Wouldn't it be great to have all those projects inside a single repository where you could easily share code and maintain consistency across the organization?

We asked ourselves these same questions when we researched the best way to introduce a new Front-End project to an existing repository. Our goal was to reuse as much code as possible to maintain consistency across projects and introduce as little complexity as possible. This is where the concept of monorepos comes into play.

Do you want to build or revamp your digital product?

What is a monorepo?

You may have heard the term "monorepo" before. A monorepo is a single repository containing multiple distinct projects, with well-defined relationships.

Are monorepo and monolith the same?

A good monorepo is the opposite of a monolith! You can read more about this and other misconceptions in Victor Savkin’s article “Misconceptions about Monorepos: Monorepo != Monolith”.

What are the advantages of using a monorepo?

Monorepos have a lot of advantages, but to make them work, you need to have the right tools. As your workspace grows, the tools have to help you keep it fast, manageable, and easy to understand.

  1. Local computation caching: the ability to store and replay file and process output of tasks. On the same machine, you will never build or test the same thing twice.
  2. Local task orchestration: the ability to run tasks in the correct order and in parallel. All tools listed below can do it in about the same way, except Lerna, which is more limited.
  3. Distributed computation caching: the ability to share cached artifacts across different environments. This means that your whole organization, including CI agents, will never build or test the same thing twice.
  4. Distributed task execution: the ability to distribute a command across multiple machines, while largely preserving the developer ergonomics of running it on a single machine.
  5. Detecting affected projects/packages: determine which projects might be affected by a change, in order to only build or run tests for those projects.
  6. Workspace analysis: the ability to understand the graph of projects within the workspace without additional configuration.
  7. Dependency graph visualization: visualize dependency relationships between projects and/or tasks. The visualization is interactive meaning that you are able to search, filter, hide, focus/highlight, and query the nodes in the graph.
  8. Code sharing: facilitates sharing of discrete pieces of source code between projects.
  9. Code generation: native support for command-based code generation.

It's not just about the features

Features matter! Things like support for distributed task execution can be game changers, especially in large monorepos. But there are other extremely important things, such as developer ergonomics, maturity, documentation, editor support, and so on.

Some tools that help you manage a monorepo

There are many solutions that aim at different goals. Each tool fits a specific set of needs and gives you a precise set of features. Here are some of the available tools:

Why Turborepo?

The problem

Monorepos have many advantages - but they struggle to scale. Each workspace has its own test suite, its own listing, and its own build process. A single monorepo might have hundreds of tasks to execute.

The solution

Turborepo solves monorepos' scaling problem. Its remote cache stores the result of all your tasks, meaning that your CI never needs to do the same work twice.

Task scheduling can be difficult in a monorepo. Imagine yarn build needs to run before yarn test across all your workspaces. Turborepo can schedule your tasks for maximum speed, across all available cores.

Turborepo can be adopted incrementally. It uses the package.json scripts you've already written, the dependencies you've already declared, and a single turbo.json file. You can use it with any package manager, like npm, yarn or pnpm. You can add it to any monorepo in just a few minutes.

What Turborepo is not

Turborepo doesn't handle package installation. Tools like npm, pnpm or yarn already do that brilliantly. But they run tasks inefficiently, meaning slow CI pipelines.

We recommend that Turborepo runs your tasks, and your favorite package manager installs your packages.

Set up a monorepo: step by step

👉 The implementation provided in this guide is available in this GitHub repository, where the commits correspond to the steps outlined throughout the guide, following the same order.

Tutorial Node.js version: 18.17.0
Tutorial npm version: 9.5.0

Step 1: Initiate a new npm project

You’ll then end up with the following file structure:

Step 2: Set up npm workspaces

npm workspaces is a generic term that refers to the set of features in the npm CLI that provides support to managing multiple packages from your local file system from within a singular top-level root package.

Let’s define the workspaces via the workspaces property of the package.json file:

We added both apps/* and packages/* which tells npm that every npm project directly inside both apps and packages folders is considered as a workspace package.

You can use any name you want for the folder names. We are going to use the apps folder for managing front-end projects and the packages folder for managing other packages that will be used by our apps Front-End projects.

By the end of this step, the file structure shall remain unchanged:

Step 3: Set up a couple of front-end projects

Let’s start adding our first front-end project by cloning the repository we created on the “How to set up a front-end project with Vite, React, and TypeScript” blog post:

This command clones the repository into the apps/project-one folder. The cloned repo has a couple of files/folders that we want to move to the repository root.

Move the .github folder by running:

Move the .husky folder by running:

After moving the Husky git-hook files, we also need to move the post install script (prepare)  and the husky npm dependency from apps/project-one/package.json to the root package.json file:

Move the .vscode folder by running:

Move the .commitlintrc file by running:

After moving the commitlint configuration file, we also need to move the its npm dependencies to the root package.json file:

Move the .nvmrc file by running:

That’s all for the monorepo base configuration files for now.

When using npm workspaces, there must be only one package-lock.json file on the repository, which must live in the repository root, meaning that we’ll need to remove every lock files inside our workspaces. For now we only have one workspace to remove from:

Before we install all npm dependencies, let’s reuse our existing .gitignore file by copying it from app/project-one into the root:

Note that each npm workspace must have a unique name. We will update the app/project-one workspace project name from demo-project to project-one:

Now we are ready to install all workspaces dependencies by running npm install on the root of the monorepo. This will create a new package-lock.json on the root folder.

Now, we should be able to run npm scripts for the project-one project by using the workspace option:

Running this command will start a local Vite development server on http://localhost:5173 and by accessing that link you should see something like the following:

At this point, we have one project working within a monorepo via npm workspaces. 🎉 Now, let’s clone the project-one project to create a second front-end project.

This command will duplicate the apps/project-one folder into apps/project-two folder.

Before we start running the project-two development server, let’s update a couple of files so we don’t end up with unexpected conflicts. First, we can update the app/project-one/index.html file to have a Project One title:

Another one we can update is the apps/project-one/src/App.tsx file by updating Hello Vite + React! to Hello Project One!:

Let’s make the same adjustments to the project-two Front-End project:

Now that we have multiple front-end projects within our monorepo, we can take advantage of npm workspaces and run scripts on multiple workspaces at the same time. So, by running the following command we can run the tests for both project-one and project-two front-end projects. Notice the --workspaces option:

When we run the previous command, the following error was raised:

This error states that we must not have multiple workspaces with the same name. This is one of the rules of workspaces. Every workspace must have a unique name. With that in mind, let’s update the project-two workspace name inside the apps/project-two/package.json file:

Now, let’s run install all dependencies again to pick up the new workspace name:

We can now try running the tests for both project-one and project-two front-end projects again:

This time the command didn’t exit with the error from before. We got the following output:

Our tests are failing because we’ve changed the HTML, but you can see from the output that the command has run the tests for the project-one and then the tests for the project-two, automatically. Before we continue, let’s update the tests to pass:

Now we should be able to run the tests and all tests should pass:

Yeah, everything is working as expected. ✌️ Following the same workflow, let’s try to run both front-end projects development servers:

After running the previous command, we can only access http://localhost:5173 which is rendering the project-one React project.

Shouldn’t we be able to access the project-two as well? Yes, but there is one catch when running scripts with the workspaces option: it only runs one script at a time. Since our development server never stops running unless we manually stop it, it will only run project-one's workspace dev script.

To run both project-one and project-two development servers, we need to run each one of them manually. On our current terminal window, let’s start project-one's development server:

Then on a new terminal window, we can start project-two's development server:

We can now access project-one in the browser at http://localhost:5173 and project-two at http://localhost:5174.

Project one development server running on http://localhost:5173
Project two development server running on http://localhost:5174

By the end of this step, the file structure shall look like the following:

Step 4: Set up shared config files

Since we’ve duplicated the project-one for creating project-two folder, we ended up with some configuration files that look exactly the same.

Ideally we would reduce code duplication as much as possible. For that, we can take advantage of npm workspaces by creating individual workspaces for our configuration files and then reuse them across the monorepo.

The main configuration files include:

ESLint

Starting with the ESLint, we’ll create a new workspace to store its configuration file using the following command:

Move the .eslintrc.json file from apps/project-one and paste it inside the new packages/eslint-config workspace folder and remove the corresponding file from apps/project-two:

Let’s change the file name from .eslintrc.json to index.js and update its contents with:

We used most of the configuration and left out the Jest configuration parts. We can create a jest.js file inside the eslint-config workspace and add the Jest specific configuration options for ESLint:

Now that we have moved the ESLint configuration files, we will move its dependencies as well. Moving all ESLint related npm dependencies into the eslint-config workspace package.json will look something like:

Remember to remove these dependencies from each project’s package.json file.

Before reusing this workspace, let’s first configure it to use its own ESLint config:

With the configuration in place, we can now add a new script to the eslint-config workspace package.json file:

With our eslint-config workspace ready to be used, we can now reference it from other workspaces. Let’s start with project-one by adding a new .eslintrc.js file with the following content:

As you can see from the code snippet above, we are using a package called eslint-config which matches the eslint-config workspace name.

In order to use it, we need to declare it as a workspace dependency as we would do for any other package on the npm registry.

We can install the eslint-config workspace as a dependency of project-one by running:

Let’s do the same for project-two:

Last but not least, lets copy the .eslintrc.js file from project-one workspace into project-two and remove the original .eslintignore files:

At this point, we have our ESLint configuration being reused on both project-one and project-two workspaces. 🎉

To ensure everything is working as intended, you can check it by running npm run lint:scripts --workspaces --if-present (the --if-present flag ensures that the command gets executed only if the specified script exists in the workspace).

Prettier

Next, we will create a new workspace for the Prettier configuration:

Following the same approach we used for the ESLint configuration, we can move the apps/project-one/.prettierrc file into the new prettier-config workspace and remove the corresponding file from apps/project-two:

Let’s rename the moved file from .prettierrc to index.js and convert its content into a CommonJS module:

Now that we have moved the Prettier configuration file, we will move its dependencies as well (once again, remember to remove these dependencies from each project’s package.json). Moving all Prettier related npm dependencies into the prettier-config workspace’s package.json will look something like:

Now, we can reference this configuration on other workspaces. But first, we need to install the prettier-config workspace package as a dependency:

Let’s update the Prettier configuration files for all workspaces.

At this point, we have our Prettier configuration being reused on both project-one, project-two, and remaining workspaces. 🎉

Once again, to ensure everything is working as intended, you can check it by running the same command we used before: npm run lint:scripts --workspaces --if-present.

Stylelint

Next, we will create a new workspace for the Stylelint configuration:

Following the same approach we used for the Prettier configuration, we can move the apps/project-one/.prettierrc file into the new stylelint-config workspace and remove the corresponding file from apps/project-two:

Now that we have moved the Stylelint configuration files, we will move its dependencies as well. Moving all Stylelint related npm dependencies into the stylelint-config workspace package.json will look something like:

Once again, remember to remove these dependencies from apps/project-one and apps/project-two.

Let’s now rename the .stylelintrc.json file we’ve previously moved to index.js and convert its content into a CommonJS module:

Let’s also configure ESLint and Prettier for this workspace:

Now, we can reference this configuration on other workspaces. But first, we need to install the stylelint-config workspace package as a dependency:

Let’s update the Stylelint configuration files for both project-one and project-two workspaces.

At this point, we have our Stylelint configuration being reused on both project-one and project-two workspaces. 🎉

To ensure everything is working as intended, you can similarly check it by running npm run lint:styles --workspaces --if-present.

Semantic Release

Next, we will create a new workspace for the semantic-release configuration:

Following the same approach we used for the Stylelint configuration, we can move the apps/project-one/.releaserc.json file into the new release-config workspace and remove the corresponding file from apps/project-two:

Now that we have moved the semantic-release configuration file, we will move its dependencies as well. Moving all semantic-release related npm dependencies into the release-config workspace package.json will look something like:

Remember to remove these dependencies from apps/project-one and apps/project-two.

Let’s now rename the .releaserc.json file we’ve previously moved to index.js and convert its content into a CommonJS module:

Let’s also configure ESLint and Prettier for this workspace:

Now, we can reference this configuration on other workspaces. But first, we need to install the release-config workspace package as a dependency:

Let’s update the semantic-release configuration files for both project-one and project-two workspaces.

At this point, we have our semantic-release configuration being reused on both project-one and project-two workspaces. 🎉

By the end of this step, the file structure shall look like the following:

Step 5: Set up shared design system

In order to set up a shared design system for our monorepo, we’ll have to:

  1. Move Storybook to a separate workspace;
  2. Namespace stories by workspace.

Currently, we have two instances of Storybook configured: one for project-one workspace and another for project-two. Let’s take advantage of our monorepo architecture and move Storybook into it’s own workspace.

We can start by creating a new workspace for all Storybook related configurations:

Now, let’s move the configuration files from project-one into the new workspace and remove the corresponding files from project-two:

Next, we can move Storybook’s dependencies from project-one's package.json file into the design-system's package.json file and remove the corresponding dependencies from project-two's package.json as well:

Let’s reuse our ESLint and Prettier configurations as well:

Create a .eslintrc.js file on the design-system workspace with the following content:

Let’s remove both storybook and build-storybook npm scripts from project-one and project-two workspaces and add them to the design-system workspace like:

With all of these updates, let’s start the Storybook development server by running the following command:

We get the following error:

We are trying to import a index.css file that does not exist on that specific path anymore because our monorepo now has a difference structure. For now, let’s remove that import statement and try to run the Storybook development server again.

Empty Storybook development server running on http://localhost:6006

Our current Storybook configuration is based on a single project folder structure but now we have multiple projects inside our monorepo.

We need to update the Storybook configuration to pick up stories from both project-one and project-two workspaces.

This can be done by updating the stories configuration on the apps/design-system/.storybook/main.ts file:

We can start the Storybook development server again and see the following error:

You can see from the error above that Storybook is trying to index the App story from project-one and project-two and since both have the same name, Storybook is showing the Duplicate stories error.

In order to be able to render both stories on Storybook, we can simply prefix the story names with the project they belong to. For example, the stories for the App component of project-one have the main title Components/App and we can prefix it with Project One to create a new hierarchy on the left sidebar menu.

Let’s update the App.stories.tsx file from project-one:

And now the App.stories.tsx file from project-two:

Now after restarting the Storybook development server we should see the project-one App story being rendered by default:

The left sidebar menu now show stories for Project One and stories for Project Two separately.

Now, we can import the global styles to render the correct font. Since we have the same index.css file on both projects, we can move it to a shared workspace and then reuse it on both projects.

Let’s start by creating a new workspace called shared inside the packages folder:

To keep the monorepo consistent, create a src folder inside the new shared workspace:

We can now move the index.css file into the folder we just created and remove the index.css file from project-two:

Now, to reuse the index.css file from the shared workspace into both project-one, project-two, and design-system we will install the shared workspace as a dependency on each of them:

Import the index.css file the from shared into Storybook by updating the apps/design-sytem/storybook/preview.js file:

Update the import on both project-one's and project-two's main.tsx file:

We should now see the correct font being used in Storybook:

By the end of this step, the file structure shall look like the following:

Step 6: Set up Turborepo

Currently, to run both project-one and project-two at the same time we need to open two different terminal windows and run the dev command for each project manually as we discussed above on the “Set up a couple of front-end projects” section.

That’s where Turborepo comes in to save the day. Turborepo is a smart build system for JavaScript/TypeScript monorepos. Unlike other build systems, Turborepo is designed to be incrementally adopted, so you can add it to most codebases in a few minutes.

Let’s follow the Turborepo’s docs to add it to our monorepo 👇

Install turbo:

Create turbo.json: file with a dev pipeline:

Edit .gitignore:

Create new dev script inside the root package.json file:

Now we can run npm run dev and Turborepo will run the dev script on all workspaces that have a dev configured. This way we can start the development servers for Storybook (design system), project one, and project two in parallel. If we don’t want to run on all workspaces, we can use the filter option to target the ones we want.

Let’s create a pipeline to build our projects as well. We will update the turbo.json file as follows:

This change has a couple different properties like dependsOn and outputs. Turborepo is smart and creates a dependency graph in order to run scripts in the most optimized way.

We tell Turborepo that when it’s time to build a workspace, it should build its dependencies first.

This is configured by the dependsOn property. The outputs property tells Turborepo what to cache after the script successfully finishes, which in our case is the output folder of Vite build command and the output folder of Storybook build command. This way, Turborepo doesn’t do the same work twice. You can read more about Turborepo’s caching on the docs.

Let’s add a new build script to our root package.json file:

The first time we run npm run build it will take some time to build everything. The second time we run the script, without making any changes, will finish almost instantaneously.

First time Turborepo stats when running npm run build:

Second time Turborepo stats when running npm run build:

As you can see, in the second time it took less than 150 milliseconds to finish the build command. When we take advantage of Turborepo’s cache, we will see a >>> FULL TURBO message on the output.

We can add another pipeline for running tests with Turborepo as well. First, we update the turbo.json file:

Let’s add a new test script to our root package.json file:

If we run npm run test it will show an error like the following:

This happens because our workspaces inside the packages folder have a test script that exits with the error above.

We can simply remove these placeholder test scripts and run npm run test again where the output should be something like:

Now if you run the command again, you should see the >>> FULL TURBO text in the output as well.

Last but not least, we can create another pipeline for the lint script. First, we update the turbo.json file:

Let’s add a new lint script to our root package.json file:

If we run npm run lint it will show the following:

As you can screen from the output, there are no workspaces with a lint script available. Both project-one and project-two have a lint:scripts and a lint:styles script.

We can create a new lint script that simply calls both scripts. So, lets add a new script to both project-one and project-two projects’ package.json file and update the lint:scripts script from both projects to enable a more verbose output of ESLint in order for Turborepo to cache it properly, using TIMING=1:

Now if we run npm run lint again, it should output something like:

We now have the dev, build, test, and lint pipelines configured! 🎉 By the end of this step, the file structure shall look like the following:

Step 7: Set CI/CD workflows

Our CI/CD workflows need to be updated in order to run for both project-one, project-two and design-system projects.

Lint and test pipelines

Currently, we have a main workflow with a single job, called lint-and-test. This works great for a simple repo, but not for a monorepo.

Since we have a lint script on most workspaces and a test script only on a couple workspaces, we can split the lint-and-test job into multiple ones so we have greater flexibility.

To do that, we will update the main.yml workflow file to look like the following:

You can see from the code above that we now have lint and test jobs. The lint job will run the lint script on all workspaces. The test job is a bit different, as we added a matrix strategy that lets us use variables in a single job definition to automatically create multiple job runs based on the combinations of the variables.

We added a variable called project as an array of project names. This lets us create two job runs, each one with its own project variable.

We then use the matrix.project variable to filter the workspaces for which we want to run the tests.

Release pipeline

With the lint and test CI/CD pipelines updates, let’s move on to the release pipeline. We have a semantic-release configuration for both project-one and project-two workspaces.

The semantic-release package was created to be used on a simple repo, not on monorepos. In order to use semantic-release within a monorepo, we need to install the semantic-release-monorepo plugin first:

Now, we are going to create a new release script inside both project-one and project-two workspaces, which executes the semantic-release -e semantic-release-monorepo command:

In order to have different Git tags for project-one and project-two workspaces, you need to update the semantic-release configurations accordingly (release.config.js):

With this update, new release tags will be formatted as {package-name}@vX.X.X depending on the changes and workspaces that they affect.

You can now add a new pipeline to Turborepo by updating the turbo.json file:

Let’s create a release script on the root of the monorepo to run the Turborepo release pipeline:

With everything ready to be used, let’s update the release.yml workflow file to use the new release Turborepo pipeline:

Chromatic pipeline

For the Chromatic pipeline, we need to build any child dependencies of the design-system workspace and run the Storybook build inside the apps/design-system folder. For that, update the chromatic.yml file to the following:

Once you’re done, the file structure shall look like the following:

And it's done! 

Managing multiple front-end projects using a monorepo can be a game-changer for development teams that are looking for improved efficiency, consistency, and scalability. By consolidating all related codebases under a single repository, developers can streamline their workflows, simplify dependency management, and promote code reusability across projects.

Ultimately, adopting a monorepo for Front-End projects empowers teams to take full control of their development processes, enabling them to innovate faster, deliver more reliable products, and maintain long-term scalability.

Embrace the monorepo approach, and watch as your development efforts become more efficient, collaborative, and future-proof! 

Meet Pixelmatters' unique approach to work.
Rui Saraiva
Front-End Developer