Caching strategies for your website: SSG, SSR, and CDN
When choosing the tech stack for a project, we often ask ourselves what kind of website we'll be building to better assess the right options for the project.

Rui Saraiva
Principal Engineer

👉 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?
You may have heard the term "monorepo" before. A monorepo is a single repository containing multiple distinct projects, with well-defined relationships.
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”.
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.
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.
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.
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.
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.
Detecting affected projects/packages: determine which projects might be affected by a change, in order to only build or run tests for those projects.
Workspace analysis: the ability to understand the graph of projects within the workspace without additional configuration.
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.
Code sharing: facilitates sharing of discrete pieces of source code between projects.
Code generation: native support for command-based code generation.
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.
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:
Bazel (by Google) - A fast, scalable, multi-language and extensible build system.
Gradle (by Gradle, Inc) - A fast, flexible polyglot build system designed for multi-project builds.
Lage (by Microsoft) - Task runner in JS monorepos.
Lerna (maintained by Nrwl) - A tool for managing JavaScript projects with multiple packages.
Nx (by Nrwl) - Next generation build system with first class monorepo support and powerful integrations.
Pants (by Pants Build) - A fast, scalable, user-friendly build system for codebases of all sizes.
Rush (by Microsoft) - Geared for large monorepos with lots of teams and projects. Part of the Rush Stack family of projects.
Turborepo (by Vercel) - The high-performance build system for JavaScript & TypeScript codebases.
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.
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.
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.
👉 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
1mkdir demo-monorepo2cd demo-monorepo3npm init -y4git init
You’ll then end up with the following file structure:
1.2└── package.json
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:
1{2 "name": "demo-monorepo",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "workspaces": [7 "apps/*",8 "packages/*"9 ],10 "devDependencies": {},11 "keywords": [],12 "author": "",13 "license": "ISC"14}
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:
12└── package.json
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:
1npx degit pixelmatters/setup-project-demo#v2.0.1 apps/project-one
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:
1mv apps/project-one/.github .github
Move the .husky folder by running:
1mv apps/project-one/.husky .husky
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:
1 "name": "demo-monorepo",2 "version": "1.0.0",3 "description": "",4 "main": "index.js",5 "workspaces": [6 "apps/*",7 "packages/*"8 ],9 "scripts": { // This object was updated10 "prepare": "husky install"11 },12 "devDependencies": { // This object was added13 "husky": "^8.0.3"14 },15 "keywords": [],16 "author": "",17 "license": "ISC"18}
Move the .vscode folder by running:
1mv apps/project-one/.vscode .vscode
Move the .commitlintrc file by running:
1mv apps/project-one/.commitlintrc.json .commitlintrc.json
After moving the commitlint configuration file, we also need to move the its npm dependencies to the root package.json file:
1{2 "name": "demo-monorepo",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "workspaces": [7 "apps/*",8 "packages/*"9 ],10 "scripts": {11 "prepare": "husky install"12 },13 "devDependencies": {14 "@commitlint/cli": "^17.6.7", // This line was added15 "@commitlint/config-conventional": "^17.6.7", // This line was added16 "husky": "^8.0.3"17 },18 "keywords": [],19 "author": "",20 "license": "ISC"21}
Move the .nvmrc file by running:
1mv apps/project-one/.nvmrc .nvmrc
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:
1rm apps/project-one/package-lock.json
Before we install all npm dependencies, let’s reuse our existing .gitignore file by copying it from app/project-one into the root:
1cp apps/project-one/.gitignore .
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:
1{2 "name": "project-one", // This line was updated3 "version": "0.0.0",4 // ...5}
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:
1npm run dev --workspace project-one2// or3npm run dev -w project-one
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.
12cp -r apps/project-one apps/project-two
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:
1<html lang="en">2 <head>3 <meta charset="UTF-8" />4 <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />6 <title>Project One</title>7 </head>8 <body>9 <div id="root"></div>10 <script type="module" src="/src/main.tsx"></script>11 </body>12</html>
Another one we can update is the apps/project-one/src/App.tsx file by updating Hello Vite + React! to Hello Project One!:
1import { useState } from 'react'2import logo from './logo.svg'3import './App.css'45export const App: React.FC = () => {6 const [count, setCount] = useState(0)78 return (9 <div className="app">10 <header className="app-header">11 <img src={logo} className="app-logo" alt="logo" />12 <p>Hello Project One!</p>{/* This line was updated */}13 <p>14 <button type="button" onClick={() => setCount((count) => count + 1)}>15 count is: {count}16 </button>17 </p>18 <p>19 Edit <code>App.tsx</code> and save to test HMR updates.20 </p>21 <p>22 <a23 className="app-link"24 href="https://reactjs.org"25 target="_blank"26 rel="noopener noreferrer"27 >28 Learn React29 </a>30 {' | '}31 <a32 className="app-link"33 href="https://vitejs.dev/guide/features.html"34 target="_blank"35 rel="noopener noreferrer"36 >37 Vite Docs38 </a>39 </p>40 </header>41 </div>42 )43}
Let’s make the same adjustments to the project-two Front-End project:
1<!DOCTYPE html>2<html lang="en">3 <head>4 <meta charset="UTF-8" />5 <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />7 <title>Project Two</title>8 </head>9 <body>10 <div id="root"></div>11 <script type="module" src="/src/main.tsx"></script>12 </body>13</html>
1import { useState } from 'react'2import logo from './logo.svg'3import './App.css'45export const App: React.FC = () => {6 const [count, setCount] = useState(0)78 return (9 <div className="app">10 <header className="app-header">11 <img src={logo} className="app-logo" alt="logo" />12 <p>Hello Project Two!</p>{/* This line was updated */}13 <p>14 <button type="button" onClick={() => setCount((count) => count + 1)}>15 count is: {count}16 </button>17 </p>18 <p>19 Edit <code>App.tsx</code> and save to test HMR updates.20 </p>21 <p>22 <a23 className="app-link"24 href="https://reactjs.org"25 target="_blank"26 rel="noopener noreferrer"27 >28 Learn React29 </a>30 {' | '}31 <a32 className="app-link"33 href="https://vitejs.dev/guide/features.html"34 target="_blank"35 rel="noopener noreferrer"36 >37 Vite Docs38 </a>39 </p>40 </header>41 </div>42 )43}
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:
1npm run test --workspaces
When we run the previous command, the following error was raised:
1❯ npm run test --workspaces2npm ERR! code EDUPLICATEWORKSPACE3npm ERR! must not have multiple workspaces with the same name4npm ERR! package 'project-one' has conflicts in the following paths:5npm ERR! /Users/ruisaraiva/dev/pixelmatters/demo-monorepo/apps/project-one6npm ERR! /Users/ruisaraiva/dev/pixelmatters/demo-monorepo/apps/project-two
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:
1{2 "name": "project-two", // This line was updated3 "version": "0.0.0",4 "engines": {5 "node": ">=18.17.0",6 "npm": ">=9.0.0"7 },8 // ...9}
Now, let’s run install all dependencies again to pick up the new workspace name:
1npm install
We can now try running the tests for both project-one and project-two front-end projects again:
1npm run test --workspaces
This time the command didn’t exit with the error from before. We got the following output:
1❯ npm run test --workspaces23> project-one@0.0.0 test4> jest56 PASS src/demo.test.ts7 FAIL src/App.test.tsx8 ● renders hello message910Test Suites: 1 failed, 1 passed, 2 total11Tests: 1 failed, 1 passed, 2 total12Snapshots: 0 total13Time: 1.932 s, estimated 3 s14Ran all test suites.1516> project-two@0.0.0 test17> jest1819 PASS src/demo.test.ts20 FAIL src/App.test.tsx21 ● renders hello message2223Test Suites: 1 failed, 1 passed, 2 total24Tests: 1 failed, 1 passed, 2 total25Snapshots: 0 total26Time: 1.637 s, estimated 2 s27Ran all test suites.
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:
1import '@testing-library/jest-dom'2import { render, screen } from '@testing-library/react'3import { App } from './App'45it('renders hello message', () => {6 render(<App />)7 expect(screen.getByText('Hello Project One!')).toBeInTheDocument() // This line was updated8})
1import '@testing-library/jest-dom'2import { render, screen } from '@testing-library/react'3import { App } from './App'45it('renders hello message', () => {6 render(<App />)7 expect(screen.getByText('Hello Project Two!')).toBeInTheDocument() // This line was updated8})
Now we should be able to run the tests and all tests should pass:
1npm run test --workspaces
1❯ npm run test --workspaces23> project-one@0.0.0 test4> jest56 PASS src/demo.test.ts7 PASS src/App.test.tsx89Test Suites: 2 passed, 2 total10Tests: 2 passed, 2 total11Snapshots: 0 total12Time: 2.037 s13Ran all test suites.1415> project-two@0.0.0 test16> jest1718 PASS src/demo.test.ts19 PASS src/App.test.tsx2021Test Suites: 2 passed, 2 total22Tests: 2 passed, 2 total23Snapshots: 0 total24Time: 1.608 s, estimated 2 s25Ran all test suites.
Yeah, everything is working as expected. ✌️ Following the same workflow, let’s try to run both front-end projects development servers:
1npm run dev --workspaces
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:
1npm run dev -w project-one
Then on a new terminal window, we can start project-two's development server:
1npm run dev -w project-two
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:
1.2├── .github3│ └── workflows4│ ├── chromatic.yml5│ ├── main.yml6│ └── release.yml7├── .husky8│ └── commit-msg9├── .vscode10│ └── settings.json11├── apps12│ ├── project-one13│ │ ├── .storybook14│ │ ├── __mocks__15│ │ ├── cypress16│ │ ├── src17│ │ ├── .eslintrc.js18│ │ ├── .gitignore19│ │ ├── .prettierrc20│ │ ├── .stylelintignore21│ │ ├── .stylelintrc.json22│ │ ├── README.md23│ │ ├── cypress.config.ts24│ │ ├── index.html25│ │ ├── jest.config.ts26│ │ ├── package.json27│ │ ├── release.config.js28│ │ ├── tsconfig.json29│ │ ├── tsconfig.node.json30│ │ └── vite.config.ts31│ └── project-two32│ ├── .storybook33│ ├── __mocks__34│ ├── cypress35│ ├── src36│ ├── .eslintrc.js37│ ├── .gitignore38│ ├── .prettierrc39│ ├── .stylelintignore40│ ├── .stylelintrc.json41│ ├── README.md42│ ├── cypress.config.ts43│ ├── index.html44│ ├── jest.config.ts45│ ├── package.json46│ ├── release.config.js47│ ├── tsconfig.json48│ ├── tsconfig.node.json49│ └── vite.config.ts50├── packages51│ ├── eslint-config52│ │ ├── .eslintrc.js53│ │ ├── .prettierrc54│ │ ├── index.js55│ │ ├── jest.js56│ │ └── package.json57│ ├── prettier-config58│ │ ├── .prettierrc59│ │ ├── index.js60│ │ └── package.json61│ ├── release-config62│ │ ├── .eslintrc.js63│ │ ├── .prettierrc64│ │ ├── index.js65│ │ └── package.json66│ └── stylelint-config67│ ├── .eslintrc.js68│ ├── .prettierrc69│ ├── index.js70│ └── package.json71├── .commitlintrc.json72├── .gitignore73├── .nvmrc74├── package-lock.json75└── package.json
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 configuration;
Prettier configuration;
Stylelint configuration;
semantic-release configuration.
Starting with the ESLint, we’ll create a new workspace to store its configuration file using the following command:
1npm init -w ./packages/eslint-config -y
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:
1mv apps/project-one/.eslintrc.json packages/eslint-config2rm apps/project-two/.eslintrc.json
Let’s change the file name from .eslintrc.json to index.js and update its contents with:
1module.exports = {2 env: {3 browser: true,4 es2021: true,5 },6 extends: [7 'eslint:recommended',8 'plugin:react/recommended',9 'plugin:react-hooks/recommended',10 'plugin:react/jsx-runtime',11 'plugin:prettier/recommended',12 'plugin:@typescript-eslint/recommended',13 'plugin:@typescript-eslint/recommended-requiring-type-checking',14 'plugin:storybook/recommended',15 ],16 parser: '@typescript-eslint/parser',17 parserOptions: {18 ecmaFeatures: {19 jsx: true,20 },21 ecmaVersion: 2021,22 sourceType: 'module',23 tsconfigRootDir: __dirname,24 project: ['./apps/**/tsconfig.json', './packages/**/tsconfig.json'],25 },26 plugins: ['react', '@typescript-eslint'],27 rules: {28 'prettier/prettier': 'error',29 },30 settings: {31 react: {32 version: 'detect',33 },34 },35};
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:
1module.exports = {2 env: {3 node: true,4 },5 overrides: [6 {7 files: ['src/**/*.test.ts', 'src/**/*.test.tsx'],8 extends: ['plugin:jest/recommended'],9 plugins: ['jest'],10 env: {11 'jest/globals': true,12 },13 },14 ],15};16
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:
1{2 "name": "eslint-config",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "dependencies": {7 "@babel/core": "^7.22.9",8 "@typescript-eslint/eslint-plugin": "^6.2.1",9 "@typescript-eslint/parser": "^6.2.1",10 "eslint": "^8.46.0",11 "eslint-config-prettier": "^8.9.0",12 "eslint-plugin-cypress": "^2.13.3",13 "eslint-plugin-jest": "^27.2.3",14 "eslint-plugin-prettier": "^5.0.0",15 "eslint-plugin-react": "^7.33.1",16 "eslint-plugin-react-hooks": "^4.6.0",17 "eslint-plugin-react-refresh": "^0.4.3",18 "eslint-plugin-storybook": "^0.6.13"19 },20 "devDependencies": {},21 "scripts": {22 "test": "echo \"Error: no test specified\" && exit 1"23 },24 "keywords": [],25 "author": "",26 "license": "ISC"27}
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:
1echo "module.exports = { extends: ['./index'].map(require.resolve) }" > packages/eslint-config/.eslintrc.js
With the configuration in place, we can now add a new script to the eslint-config workspace package.json file:
1{2 "name": "eslint-config",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "dependencies": {7 "@babel/core": "^7.22.9",8 "@typescript-eslint/eslint-plugin": "^6.2.1",9 "@typescript-eslint/parser": "^6.2.1",10 "eslint": "^8.46.0",11 "eslint-config-prettier": "^8.9.0",12 "eslint-plugin-cypress": "^2.13.3",13 "eslint-plugin-jest": "^27.2.3",14 "eslint-plugin-prettier": "^5.0.0",15 "eslint-plugin-react": "^7.33.1",16 "eslint-plugin-react-hooks": "^4.6.0",17 "eslint-plugin-react-refresh": "^0.4.3",18 "eslint-plugin-storybook": "^0.6.13"19 },20 "devDependencies": {},21 "scripts": {22 "lint:scripts": "eslint ." // This line was updated23 },24 "keywords": [],25 "author": "",26 "license": "ISC"27}
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:
1module.exports = {2 extends: ['eslint-config', 'eslint-config/jest'].map(require.resolve),3 parserOptions: {4 tsconfigRootDir: __dirname,5 project: './tsconfig.json',6 },7 ignorePatterns: ['cypress/**/*', 'dist', '.eslintrc.js'],8}
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:
1npm install -D eslint-config -w project-one
Let’s do the same for project-two:
1npm install -D eslint-config -w 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:
1cp apps/project-one/.eslintrc.js apps/project-two2rm apps/project-one/.eslintignore3rm apps/project-two/.eslintignore
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).
Next, we will create a new workspace for the Prettier configuration:
1npm init -w ./packages/prettier-config -y
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:
1mv apps/project-one/.prettierrc packages/prettier-config2rm apps/project-two/.prettierrc
Let’s rename the moved file from .prettierrc to index.js and convert its content into a CommonJS module:
1module.exports = {2 semi: false,3 singleQuote: true,4}
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:
1{2 "name": "prettier-config",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "dependencies": { // This object was added7 "prettier": "^3.0.0"8 },9 "devDependencies": {},10 "scripts": {11 "test": "echo \"Error: no test specified\" && exit 1"12 },13 "keywords": [],14 "author": "",15 "license": "ISC"16}
Now, we can reference this configuration on other workspaces. But first, we need to install the prettier-config workspace package as a dependency:
1npm install -D prettier-config -w project-one2npm install -D prettier-config -w project-two3npm install -D prettier-config -w eslint-config
Let’s update the Prettier configuration files for all workspaces.
1echo "\"prettier-config\"" > apps/project-one/.prettierrc2echo "\"prettier-config\"" > apps/project-two/.prettierrc3echo "\"prettier-config\"" > packages/eslint-config/.prettierrc4echo "\"./index.js\"" > packages/prettier-config/.prettierrc
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.
Next, we will create a new workspace for the Stylelint configuration:
1npm init -w ./packages/stylelint-config -y
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:
1mv apps/project-one/.stylelintrc.json packages/stylelint-config2rm apps/project-two/.stylelintrc.json
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:
1{2 "name": "stylelint-config",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "dependencies": {7 "stylelint": "^15.10.2", // This line was added8 "stylelint-config-recess-order": "^4.3.0", // This line was added9 "stylelint-config-standard": "^34.0.0" // This line was added10 },11 "devDependencies": {},12 "scripts": {13 "test": "echo \"Error: no test specified\" && exit 1"14 },15 "keywords": [],16 "author": "",17 "license": "ISC"18}
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:
1module.exports = {2 extends: ['stylelint-config-standard', 'stylelint-config-recess-order'],3}
Let’s also configure ESLint and Prettier for this workspace:
1npm install -D eslint-config prettier-config -w stylelint-config2echo "\"prettier-config\"" > packages/stylelint-config/.prettierrc3echo "module.exports = { extends: ['eslint-config'].map(require.resolve) }" > packages/stylelint-config/.eslintrc.js
Now, we can reference this configuration on other workspaces. But first, we need to install the stylelint-config workspace package as a dependency:
1npm install -D stylelint-config -w project-one2npm install -D stylelint-config -w project-two
Let’s update the Stylelint configuration files for both project-one and project-two workspaces.
1echo "{ \"extends\": \"stylelint-config\" }" > apps/project-one/.stylelintrc.json2echo "{ \"extends\": \"stylelint-config\" }" > apps/project-two/.stylelintrc.json
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.
Next, we will create a new workspace for the semantic-release configuration:
1npm init -w ./packages/release-config -y
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:
1mv apps/project-one/.releaserc.json packages/release-config2rm apps/project-two/.releaserc.json
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:
1{2 "name": "release-config",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "dependencies": { // This object was added7 "semantic-release": "^21.0.7"8 },9 "devDependencies": {},10 "scripts": {11 "test": "echo \"Error: no test specified\" && exit 1"12 },13 "keywords": [],14 "author": "",15 "license": "ISC"16}
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:
1module.exports = {2 branches: ['main'],3 plugins: [4 '@semantic-release/commit-analyzer',5 '@semantic-release/release-notes-generator',6 '@semantic-release/github',7 ],8}
Let’s also configure ESLint and Prettier for this workspace:
1npm install -D eslint-config prettier-config -w release-config2echo "\"prettier-config\"" > packages/release-config/.prettierrc3echo "module.exports = { extends: ['eslint-config'].map(require.resolve) }" > packages/release-config/.eslintrc.js
Now, we can reference this configuration on other workspaces. But first, we need to install the release-config workspace package as a dependency:
1npm install -D release-config -w project-one2npm install -D release-config -w project-two
Let’s update the semantic-release configuration files for both project-one and project-two workspaces.
1echo "module.exports = require('release-config')" > apps/project-one/release.config.js2echo "module.exports = require('release-config')" > apps/project-two/release.config.js
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:
1.2├── .github3│ └── workflows4│ ├── chromatic.yml5│ ├── main.yml6│ └── release.yml7├── .husky8│ └── commit-msg9├── .vscode10│ └── settings.json11├── apps12│ ├── project-one13│ │ ├── .storybook14│ │ ├── __mocks__15│ │ ├── cypress16│ │ ├── src17│ │ ├── .eslintrc.js18│ │ ├── .gitignore19│ │ ├── .prettierrc20│ │ ├── .stylelintignore21│ │ ├── .stylelintrc.json22│ │ ├── README.md23│ │ ├── cypress.config.ts24│ │ ├── index.html25│ │ ├── jest.config.ts26│ │ ├── package.json27│ │ ├── release.config.js28│ │ ├── tsconfig.json29│ │ ├── tsconfig.node.json30│ │ └── vite.config.ts31│ └── project-two32│ ├── .storybook33│ ├── __mocks__34│ ├── cypress35│ ├── src36│ ├── .eslintrc.js37│ ├── .gitignore38│ ├── .prettierrc39│ ├── .stylelintignore40│ ├── .stylelintrc.json41│ ├── README.md42│ ├── cypress.config.ts43│ ├── index.html44│ ├── jest.config.ts45│ ├── package.json46│ ├── release.config.js47│ ├── tsconfig.json48│ ├── tsconfig.node.json49│ └── vite.config.ts50├── packages51│ ├── eslint-config52│ │ ├── .eslintrc.js53│ │ ├── .prettierrc54│ │ ├── index.js55│ │ ├── jest.js56│ │ └── package.json57│ ├── prettier-config58│ │ ├── .prettierrc59│ │ ├── index.js60│ │ └── package.json61│ ├── release-config62│ │ ├── .eslintrc.js63│ │ ├── .prettierrc64│ │ ├── index.js65│ │ └── package.json66│ └── stylelint-config67│ ├── .eslintrc.js68│ ├── .prettierrc69│ ├── index.js70│ └── package.json71├── .commitlintrc.json72├── .gitignore73├── .nvmrc74├── package-lock.json75└── package.json
In order to set up a shared design system for our monorepo, we’ll have to:
Move Storybook to a separate workspace;
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:
1npm init -w ./apps/design-system -y
Now, let’s move the configuration files from project-one into the new workspace and remove the corresponding files from project-two:
1mv apps/project-one/.storybook apps/design-system2rm -rf apps/project-two/.storybook
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:
1{2 "name": "design-system",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "devDependencies": {7 "@storybook/addon-essentials": "^7.2.0", // This line was added8 "@storybook/addon-interactions": "^7.2.0", // This line was added9 "@storybook/addon-links": "^7.2.0", // This line was added10 "@storybook/addon-onboarding": "^1.0.8", // This line was added11 "@storybook/blocks": "^7.2.0", // This line was added12 "@storybook/react": "^7.2.0", // This line was added13 "@storybook/react-vite": "^7.2.0", // This line was added14 "@storybook/testing-library": "^0.2.0", // This line was added15 "storybook": "^7.2.0" // This line was added16 },17 "scripts": {18 "test": "echo \"Error: no test specified\" && exit 1"19 },20 "keywords": [],21 "author": "",22 "license": "ISC"23}
Let’s reuse our ESLint and Prettier configurations as well:
1npm install -D eslint-config -w design-system2npm install -D prettier-config -w design-system3echo "\"prettier-config\"" > apps/design-system/.prettierrc
Create a .eslintrc.js file on the design-system workspace with the following content:
1module.exports = {2 extends: ['eslint-config'].map(require.resolve),3 ignorePatterns: ['!.storybook'],4}
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:
1{2 "name": "design-system",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "devDependencies": {7 "@storybook/addon-essentials": "^7.2.0",8 "@storybook/addon-interactions": "^7.2.0",9 "@storybook/addon-links": "^7.2.0",10 "@storybook/addon-onboarding": "^1.0.8",11 "@storybook/blocks": "^7.2.0",12 "@storybook/react": "^7.2.0",13 "@storybook/react-vite": "^7.2.0",14 "@storybook/testing-library": "^0.2.0",15 "eslint-config": "^1.0.0",16 "prettier-config": "^1.0.0",17 "storybook": "^7.2.0"18 },19 "scripts": {20 "dev": "storybook dev -p 6006", // This line was added21 "build": "storybook build" // This line was added22 },23 "keywords": [],24 "author": "",25 "license": "ISC"26}
With all of these updates, let’s start the Storybook development server by running the following command:
1npm run dev -w apps/design-system
We get the following error:
1[vite] Internal server error: Failed to resolve import "../src/index.css" from ".storybook/preview.js". Does the file exist?
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:
1import type { StorybookConfig } from '@storybook/react-vite'23const config: StorybookConfig = {4 stories: ['../../*/src/**/*.stories.@(js|jsx|ts|tsx)'], // This line was updated5 addons: [6 '@storybook/addon-links',7 '@storybook/addon-essentials',8 '@storybook/addon-onboarding',9 '@storybook/addon-interactions',10 ],11 framework: {12 name: '@storybook/react-vite',13 options: {},14 },15 docs: {16 autodocs: 'tag',17 },18}19export default config
We can start the Storybook development server again and see the following error:
1🚨 Unable to index ../project-one/src/App.stories.tsx,../project-two/src/App.stories.tsx:2 Error: Duplicate stories with id: components-app--default
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:
1import { Meta, StoryObj } from '@storybook/react'23import { App } from './App'45const meta = {6 title: 'Project One/Components/App', // This line was updated7 component: App,8} satisfies Meta<typeof App>910export default meta11type Story = StoryObj<typeof meta>1213export const Default = {} satisfies Story
And now the App.stories.tsx file from project-two:
1import { Meta, StoryObj } from '@storybook/react'23import { App } from './App'45const meta = {6 title: 'Project Two/Components/App', // This line was updated7 component: App,8} satisfies Meta<typeof App>910export default meta11type Story = StoryObj<typeof meta>1213export const Default = {} satisfies Story
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:
1npm init -w ./packages/shared -y
To keep the monorepo consistent, create a src folder inside the new shared workspace:
1mkdir packages/shared/src
We can now move the index.css file into the folder we just created and remove the index.css file from project-two:
1mv apps/project-one/src/index.css packages/shared/src/2rm apps/project-two/src/index.css
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:
1npm install shared -w apps/project-one2npm install shared -w apps/project-two3npm install shared -w apps/design-system
Import the index.css file the from shared into Storybook by updating the apps/design-sytem/storybook/preview.js file:
1import type { Preview } from '@storybook/react'2import 'shared/src/index.css' // This line was added34const preview: Preview = {5 parameters: {6 actions: { argTypesRegex: '^on[A-Z].*' },7 controls: {8 matchers: {9 color: /(background|color)$/i,10 date: /Date$/,11 },12 },13 layout: 'fullscreen',14 },15}1617export default preview
Update the import on both project-one's and project-two's main.tsx file:
1import React from 'react'2import ReactDOM from 'react-dom/client'3import 'shared/src/index.css' // This line was updated4import { App } from './App'56ReactDOM.createRoot(document.getElementById('root')!).render(7 <React.StrictMode>8 <App />9 </React.StrictMode>,10)
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:
1.2├── .github3│ └── workflows4│ ├── chromatic.yml5│ ├── main.yml6│ └── release.yml7├── .husky8│ └── commit-msg9├── .vscode10│ └── settings.json11├── apps12│ ├── design-system13│ │ ├── .storybook14│ │ ├── .eslintrc.js15│ │ ├── .prettierrc16│ │ └── package.json17│ ├── project-one18│ │ ├── __mocks__19│ │ ├── cypress20│ │ ├── src21│ │ ├── .eslintrc.js22│ │ ├── .gitignore23│ │ ├── .prettierrc24│ │ ├── .stylelintignore25│ │ ├── .stylelintrc.json26│ │ ├── README.md27│ │ ├── cypress.config.ts28│ │ ├── index.html29│ │ ├── jest.config.ts30│ │ ├── package.json31│ │ ├── release.config.js32│ │ ├── tsconfig.json33│ │ ├── tsconfig.node.json34│ │ └── vite.config.ts35│ └── project-two36│ ├── .storybook37│ ├── __mocks__38│ ├── cypress39│ ├── src40│ ├── .eslintrc.js41│ ├── .gitignore42│ ├── .prettierrc43│ ├── .stylelintignore44│ ├── .stylelintrc.json45│ ├── README.md46│ ├── cypress.config.ts47│ ├── index.html48│ ├── jest.config.ts49│ ├── package.json50│ ├── release.config.js51│ ├── tsconfig.json52│ ├── tsconfig.node.json53│ └── vite.config.ts54├── packages55│ ├── eslint-config56│ │ ├── .eslintrc.js57│ │ ├── .prettierrc58│ │ ├── index.js59│ │ ├── jest.js60│ │ └── package.json61│ ├── prettier-config62│ │ ├── .prettierrc63│ │ ├── index.js64│ │ └── package.json65│ ├── release-config66│ │ ├── .eslintrc.js67│ │ ├── .prettierrc68│ │ ├── index.js69│ │ └── package.json70│ ├── shared71│ │ ├── src72│ │ └── package.json73│ └── stylelint-config74│ ├── .eslintrc.js75│ ├── .prettierrc76│ ├── index.js77│ └── package.json78├── .commitlintrc.json79├── .gitignore80├── .nvmrc81├── package-lock.json82└── package.json
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:
1npm install turbo -D
Create turbo.json: file with a dev pipeline:
1{2 "$schema": "https://turborepo.org/schema.json",3 "pipeline": {4 "dev": {5 "cache": false6 },7 }8}
Edit .gitignore:
1node_modules2.DS_Store3dist4dist-ssr5*.local6storybook-static7# The next line was added8.turbo
Create new dev script inside the root package.json file:
1{2 "name": "demo-monorepo",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "workspaces": [7 "apps/*",8 "packages/*"9 ],10 "scripts": {11 "prepare": "husky install",12 "dev": "turbo dev --parallel --continue" // This line was added13 },14 "devDependencies": {15 "@commitlint/cli": "^17.6.7",16 "@commitlint/config-conventional": "^17.6.7",17 "husky": "^8.0.3",18 "turbo": "^1.10.12"19 },20 "keywords": [],21 "author": "",22 "license": "ISC"23}
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:
1{2 "$schema": "https://turborepo.org/schema.json",3 "pipeline": {4 "dev": {5 "cache": false6 },7 "build": { // This object was added8 "dependsOn": ["^build"],9 "outputs": ["dist/**", "storybook-static/**"]10 }11 }12}
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:
1{2 "name": "demo-monorepo",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "workspaces": [7 "apps/*",8 "packages/*"9 ],10 "scripts": {11 "prepare": "husky install",12 "dev": "turbo dev --parallel --continue",13 "build": "turbo build" // This line was added14 },15 "devDependencies": {16 "@commitlint/cli": "^17.6.7",17 "@commitlint/config-conventional": "^17.6.7",18 "husky": "^8.0.3",19 "turbo": "^1.10.12"20 },21 "keywords": [],22 "author": "",23 "license": "ISC"24}
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:
1Tasks: 3 successful, 3 total2Cached: 0 cached, 3 total3 Time: 8.067s
Second time Turborepo stats when running npm run build:
1Tasks: 3 successful, 3 total2Cached: 3 cached, 3 total3 Time: 128ms >>> FULL TURBO
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:
1{2 "$schema": "https://turborepo.org/schema.json",3 "pipeline": {4 "dev": {5 "cache": false6 },7 "build": {8 "dependsOn": ["^build"],9 "outputs": ["dist/**", "storybook-static/**"]10 },11 "test": { // This object was added12 "dependsOn": ["^build"],13 "outputs": [],14 "inputs": ["src/**/*.tsx", "src/**/*.ts"]15 }16 }17}
Let’s add a new test script to our root package.json file:
1{2 "name": "demo-monorepo",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "workspaces": [7 "apps/*",8 "packages/*"9 ],10 "scripts": {11 "prepare": "husky install",12 "dev": "turbo dev --parallel --continue",13 "build": "turbo build",14 "test": "turbo test" // This line was added15 },16 "devDependencies": {17 "@commitlint/cli": "^17.6.7",18 "@commitlint/config-conventional": "^17.6.7",19 "husky": "^8.0.3",20 "turbo": "^1.10.12"21 },22 "keywords": [],23 "author": "",24 "license": "ISC"25}
If we run npm run test it will show an error like the following:
1command (/Users/ruisaraiva/.../demo-monorepo/packages/shared) npm run test exited (1)2command (/Users/ruisaraiva/.../demo-monorepo/packages/stylelint-config) npm run test exited (1)34 Tasks: 0 successful, 2 total5Cached: 0 cached, 2 total6 Time: 794ms78 ERROR run failed: command exited (1)
This happens because our workspaces inside the packages folder have a test script that exits with the error above.
1"scripts": {2 "test": "echo \"Error: no test specified\" && exit 1" // Remove this line3},
We can simply remove these placeholder test scripts and run npm run test again where the output should be something like:
1❯ npm run test23> demo-monorepo@1.0.0 test4> turbo test56• Packages in scope: design-system, eslint-config, prettier-config, project-one, project-two, release-config, shared, stylelint-config7• Running test in 8 packages8• Remote caching disabled9project-two:test: cache miss, executing 6f5ffb2848ba9d1110project-one:test: cache miss, executing 44571033308de93b11project-one:test:12project-one:test: > project-one@0.0.0 test13project-one:test: > jest14project-one:test:15project-two:test:16project-two:test: > project-two@0.0.0 test17project-two:test: > jest18project-two:test:19project-one:test: PASS src/App.test.tsx20project-two:test: PASS src/App.test.tsx21project-two:test: PASS src/demo.test.ts22project-one:test: PASS src/demo.test.ts23project-two:test:24project-two:test: Test Suites: 2 passed, 2 total25project-two:test: Tests: 2 passed, 2 total26project-two:test: Snapshots: 0 total27project-two:test: Time: 0.787 s, estimated 1 s28project-two:test: Ran all test suites.29project-one:test:30project-one:test: Test Suites: 2 passed, 2 total31project-one:test: Tests: 2 passed, 2 total32project-one:test: Snapshots: 0 total33project-one:test: Time: 0.787 s, estimated 1 s34project-one:test: Ran all test suites.3536 Tasks: 2 successful, 2 total37Cached: 0 cached, 2 total38 Time: 2.946s
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:
1{2 "$schema": "https://turborepo.org/schema.json",3 "pipeline": {4 "dev": {5 "cache": false6 },7 "build": {8 "dependsOn": ["^build"],9 "outputs": ["dist/**", "storybook-static/**"]10 },11 "test": {12 "dependsOn": ["^build"],13 "outputs": [],14 "inputs": ["src/**/*.tsx", "src/**/*.ts"]15 },16 "lint": { // This object was added17 "dependsOn": ["^build"],18 "outputs": []19 }20 }21}
Let’s add a new lint script to our root package.json file:
1{2 "name": "demo-monorepo",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "workspaces": [7 "apps/*",8 "packages/*"9 ],10 "scripts": {11 "prepare": "husky install",12 "dev": "turbo dev --parallel --continue",13 "build": "turbo build",14 "test": "turbo test",15 "lint": "turbo lint" // This line was added16 },17 "devDependencies": {18 "@commitlint/cli": "^17.6.7",19 "@commitlint/config-conventional": "^17.6.7",20 "husky": "^8.0.3",21 "turbo": "^1.10.12"22 },23 "keywords": [],24 "author": "",25 "license": "ISC"26}
If we run npm run lint it will show the following:
1❯ npm run lint23> demo-monorepo@1.0.0 lint4> turbo lint56• Packages in scope: design-system, eslint-config, prettier-config, project-one, project-two, release-config, shared, stylelint-config7• Running lint in 8 packages8• Remote caching disabled910No tasks were executed as part of this run.1112 Tasks: 0 successful, 0 total13Cached: 0 cached, 0 total14 Time: 146ms
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:
1{2 "name": "project-one",3 "version": "0.0.0",4 "engines": {5 "node": ">=18.17.0",6 "npm": ">=9.0.0"7 },8 "scripts": {9 "dev": "vite",10 "build": "vite build",11 "serve": "vite preview",12 "lint": "npm run lint:scripts && npm run lint:styles", // This line was added13 "lint:scripts": "TIMING=1 eslint --ext .ts,.tsx .", // This line was updated14 "lint:styles": "stylelint \"src/**/*.css\"",15 "test": "jest",16 "cypress:open": "cypress open"17 },18 // ...19}20
Now if we run npm run lint again, it should output something like:
1❯ npm run lint23> demo-monorepo@1.0.0 lint4> turbo lint56• Packages in scope: design-system, eslint-config, prettier-config, project-one, project-two, release-config, shared, stylelint-config7• Running lint in 8 packages8• Remote caching disabled9project-two:lint: cache miss, executing c1987b986258151d10project-one:lint: cache miss, executing 31c5a2d919a903ba11project-one:lint:12project-one:lint: > project-one@0.0.0 lint13project-one:lint: > npm run lint:scripts && npm run lint:styles14project-one:lint:15project-two:lint:16project-two:lint: > project-two@0.0.0 lint17project-two:lint: > npm run lint:scripts && npm run lint:styles18project-two:lint:19project-one:lint:20project-one:lint: > project-one@0.0.0 lint:scripts21project-one:lint: > TIMING=1 eslint --ext .ts,.tsx .22project-one:lint:23project-two:lint:24project-two:lint: > project-two@0.0.0 lint:scripts25project-two:lint: > TIMING=1 eslint --ext .ts,.tsx .26project-two:lint:27project-one:lint: Rule | Time (ms) | Relative28project-one:lint: :---------------------------------------|----------:|--------:29project-one:lint: prettier/prettier | 205.426 | 52.1%30project-one:lint: @typescript-eslint/no-misused-promises | 85.829 | 21.8%31project-one:lint: @typescript-eslint/no-floating-promises | 39.324 | 10.0%32project-one:lint: @typescript-eslint/no-unsafe-assignment | 14.449 | 3.7%33project-one:lint: @typescript-eslint/no-unsafe-argument | 14.211 | 3.6%34project-one:lint: react/display-name | 7.677 | 1.9%35project-one:lint: @typescript-eslint/no-unused-vars | 4.060 | 1.0%36project-one:lint: react/no-direct-mutation-state | 1.607 | 0.4%37project-one:lint: @typescript-eslint/no-unsafe-return | 1.424 | 0.4%38project-one:lint: react/no-unknown-property | 1.221 | 0.3%39project-two:lint: Rule | Time (ms) | Relative40project-two:lint: :---------------------------------------|----------:|--------:41project-two:lint: prettier/prettier | 216.367 | 53.2%42project-two:lint: @typescript-eslint/no-misused-promises | 85.785 | 21.1%43project-two:lint: @typescript-eslint/no-floating-promises | 38.538 | 9.5%44project-two:lint: @typescript-eslint/no-unsafe-argument | 14.407 | 3.5%45project-two:lint: @typescript-eslint/no-unsafe-assignment | 14.117 | 3.5%46project-two:lint: react/display-name | 7.757 | 1.9%47project-two:lint: @typescript-eslint/no-unused-vars | 5.955 | 1.5%48project-two:lint: @typescript-eslint/no-unsafe-return | 1.787 | 0.4%49project-two:lint: react/no-direct-mutation-state | 1.694 | 0.4%50project-two:lint: react/no-unknown-property | 1.288 | 0.3%51project-one:lint:52project-one:lint: > project-one@0.0.0 lint:styles53project-one:lint: > stylelint "src/**/*.css"54project-one:lint:55project-two:lint:56project-two:lint: > project-two@0.0.0 lint:styles57project-two:lint: > stylelint "src/**/*.css"58project-two:lint:5960 Tasks: 2 successful, 2 total61Cached: 0 cached, 2 total62 Time: 4.902s
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:
1.2├── .github3│ └── workflows4│ ├── chromatic.yml5│ ├── main.yml6│ └── release.yml7├── .husky8│ └── commit-msg9├── .vscode10│ └── settings.json11├── apps12│ ├── design-system13│ │ ├── .storybook14│ │ ├── .eslintrc.js15│ │ ├── .prettierrc16│ │ └── package.json17│ ├── project-one18│ │ ├── __mocks__19│ │ ├── cypress20│ │ ├── src21│ │ ├── .eslintrc.js22│ │ ├── .gitignore23│ │ ├── .prettierrc24│ │ ├── .stylelintignore25│ │ ├── .stylelintrc.json26│ │ ├── README.md27│ │ ├── cypress.config.ts28│ │ ├── index.html29│ │ ├── jest.config.ts30│ │ ├── package.json31│ │ ├── release.config.js32│ │ ├── tsconfig.json33│ │ ├── tsconfig.node.json34│ │ └── vite.config.ts35│ └── project-two36│ ├── .storybook37│ ├── __mocks__38│ ├── cypress39│ ├── src40│ ├── .eslintrc.js41│ ├── .gitignore42│ ├── .prettierrc43│ ├── .stylelintignore44│ ├── .stylelintrc.json45│ ├── README.md46│ ├── cypress.config.ts47│ ├── index.html48│ ├── jest.config.ts49│ ├── package.json50│ ├── release.config.js51│ ├── tsconfig.json52│ ├── tsconfig.node.json53│ └── vite.config.ts54├── packages55│ ├── eslint-config56│ │ ├── .eslintrc.js57│ │ ├── .prettierrc58│ │ ├── index.js59│ │ ├── jest.js60│ │ └── package.json61│ ├── prettier-config62│ │ ├── .prettierrc63│ │ ├── index.js64│ │ └── package.json65│ ├── release-config66│ │ ├── .eslintrc.js67│ │ ├── .prettierrc68│ │ ├── index.js69│ │ └── package.json70│ ├── shared71│ │ ├── src72│ │ └── package.json73│ └── stylelint-config74│ ├── .eslintrc.js75│ ├── .prettierrc76│ ├── index.js77│ └── package.json78├── .commitlintrc.json79├── .gitignore80├── .nvmrc81├── package-lock.json82├── package.json83└── turbo.json
Our CI/CD workflows need to be updated in order to run for both project-one, project-two and design-system projects.
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:
1name: "Main"2on: pull_request3jobs:4 lint:5 runs-on: ubuntu-latest6 steps:7 - uses: actions/checkout@v38 - name: Setup Node.js9 uses: actions/setup-node@v310 with:11 node-version-file: ".nvmrc"12 cache: "npm"13 - name: Install dependencies14 run: npm ci15 - name: Run Linters16 run: npm run lint1718 test:19 runs-on: ubuntu-latest20 strategy:21 matrix:22 project: [project-one, project-two]23 fail-fast: false24 steps:25 - uses: actions/checkout@v326 - name: Setup Node.js27 uses: actions/setup-node@v328 with:29 node-version-file: ".nvmrc"30 cache: "npm"31 - name: Install dependencies32 run: npm ci33 - name: Run Tests34 run: npm test -- --filter="{${{ matrix.project }}}..."35 env:36 CI: true37 - name: Install Cypress dependencies38 uses: cypress-io/github-action@v539 with:40 # just perform install41 runTests: false42 - name: Run Cypress43 uses: cypress-io/github-action@v544 with:45 build: npm run build46 start: npm run serve -- --port=517347 wait-on: "http://127.0.0.1:5173"48 browser: chrome49 # we have already installed all dependencies above50 install: false51 working-directory: apps/${{ matrix.project }}
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.
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:
1npm install -D semantic-release-monorepo -w project-one2npm install -D semantic-release-monorepo -w project-two
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:
1{2 "name": "project-one",3 "version": "0.0.0",4 "engines": {5 "node": ">=18.17.0",6 "npm": ">=9.0.0"7 },8 "scripts": {9 "dev": "vite --host",10 "build": "vite build",11 "serve": "vite preview --host",12 "lint": "npm run lint:scripts && npm run lint:styles",13 "lint:scripts": "TIMING=1 eslint --ext .ts,.tsx .",14 "lint:styles": "stylelint \"src/**/*.css\"",15 "test": "jest",16 "cypress:open": "cypress open",17 "release": "semantic-release -e semantic-release-monorepo" // This line was added18 },19 // ...20}
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):
1const sharedConfig = require('release-config')2const { name } = require('./package.json')34module.exports = {5 ...sharedConfig,6 tagFormat: `${name}@\${version}`,7}
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:
1{2 "$schema": "https://turborepo.org/schema.json",3 "pipeline": {4 "dev": {5 "cache": false6 },7 "build": {8 "dependsOn": ["^build"],9 "outputs": ["dist/**", "storybook-static/**"]10 },11 "test": {12 "dependsOn": ["^build"],13 "outputs": [],14 "inputs": ["src/**/*.tsx", "src/**/*.ts"]15 },16 "lint": {17 "dependsOn": ["^build"],18 "outputs": []19 },20 "release": { // This object was added21 "cache": false22 }23 }24}
Let’s create a release script on the root of the monorepo to run the Turborepo release pipeline:
1{2 "name": "demo-monorepo",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "workspaces": [7 "apps/*",8 "packages/*"9 ],10 "scripts": {11 "prepare": "husky install",12 "dev": "turbo dev --parallel --continue",13 "build": "turbo build",14 "test": "turbo test",15 "lint": "turbo lint",16 "release": "turbo release --concurrency=1" // This line was added17 },18 "devDependencies": {19 "@commitlint/cli": "^17.6.7",20 "@commitlint/config-conventional": "^17.6.7",21 "husky": "^8.0.3",22 "turbo": "^1.10.12"23 },24 "keywords": [],25 "author": "",26 "license": "ISC"27}
With everything ready to be used, let’s update the release.yml workflow file to use the new release Turborepo pipeline:
1name: "Release"2on:3 push:4 branches:5 - main6jobs:7 semantic-release:8 runs-on: ubuntu-latest9 steps:10 - uses: actions/checkout@v311 - name: Setup Node.js12 uses: actions/setup-node@v313 with:14 node-version-file: ".nvmrc"15 cache: "npm"16 - name: Install dependencies17 run: npm ci18 - name: Release a new version19 # 👇 This line was updated20 run: npm run release21 env:22 GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
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:
1name: "Chromatic"2on: push3jobs:4 chromatic-deployment:5 runs-on: ubuntu-latest6 steps:7 - name: Checkout repo8 uses: actions/checkout@v39 with:10 fetch-depth: 5011 - name: Use Node.js12 uses: actions/setup-node@v313 with:14 node-version-file: ".nvmrc"15 cache: "npm"16 - name: Install dependencies17 run: npm ci18 # 👇 This step was added19 - name: Build Storybook dependencies20 run: npm run build -- --filter="design-system^..."21 - name: Publish to Chromatic22 uses: chromaui/action@v123 with:24 # 👇 This line was added25 workingDir: apps/design-system26 # 👇 This line was added27 buildScriptName: build28 token: ${{ secrets.GITHUB_TOKEN }}29 projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}30 autoAcceptChanges: ${{ github.ref == 'refs/heads/main' }}31 exitOnceUploaded: true32 skip: "dependabot/**"
Once you’re done, the file structure shall look like the following:
1.2├── .github3│ └── workflows4│ ├── chromatic.yml5│ ├── main.yml6│ └── release.yml7├── .husky8│ └── commit-msg9├── .vscode10│ └── settings.json11├── apps12│ ├── design-system13│ │ ├── .storybook14│ │ ├── .eslintrc.js15│ │ ├── .prettierrc16│ │ └── package.json17│ ├── project-one18│ │ ├── __mocks__19│ │ ├── cypress20│ │ ├── src21│ │ ├── .eslintrc.js22│ │ ├── .gitignore23│ │ ├── .prettierrc24│ │ ├── .stylelintignore25│ │ ├── .stylelintrc.json26│ │ ├── README.md27│ │ ├── cypress.config.ts28│ │ ├── index.html29│ │ ├── jest.config.ts30│ │ ├── package.json31│ │ ├── release.config.js32│ │ ├── tsconfig.json33│ │ ├── tsconfig.node.json34│ │ └── vite.config.ts35│ └── project-two36│ ├── .storybook37│ ├── __mocks__38│ ├── cypress39│ ├── src40│ ├── .eslintrc.js41│ ├── .gitignore42│ ├── .prettierrc43│ ├── .stylelintignore44│ ├── .stylelintrc.json45│ ├── README.md46│ ├── cypress.config.ts47│ ├── index.html48│ ├── jest.config.ts49│ ├── package.json50│ ├── release.config.js51│ ├── tsconfig.json52│ ├── tsconfig.node.json53│ └── vite.config.ts54├── packages55│ ├── eslint-config56│ │ ├── .eslintrc.js57│ │ ├── .prettierrc58│ │ ├── index.js59│ │ ├── jest.js60│ │ └── package.json61│ ├── prettier-config62│ │ ├── .prettierrc63│ │ ├── index.js64│ │ └── package.json65│ ├── release-config66│ │ ├── .eslintrc.js67│ │ ├── .prettierrc68│ │ ├── index.js69│ │ └── package.json70│ ├── shared71│ │ ├── src72│ │ └── package.json73│ └── stylelint-config74│ ├── .eslintrc.js75│ ├── .prettierrc76│ ├── index.js77│ └── package.json78├── .commitlintrc.json79├── .gitignore80├── .nvmrc81├── package-lock.json82├── package.json83└── turbo.json
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.
Share this article