Task 11: Set up a Monorepo
Now that we have made significant progress on both the frontend and backend of our Posts app, it's time to combine them into a monorepo using PNPM workspaces. This approach will enable us to manage both packages within a single repository and leverage benefits such as unified dependencies and simplified cross-package workflows.
What is a monorepo?
A monorepo, short for "monolithic repository", is a software development approach where multiple projects or packages are stored in a single repository. In a monorepo, all the code for different projects or packages is managed together, rather than having separate repositories for each project.
People use monorepos for various reasons:
- Code Sharing and Reusability: With a monorepo, code can be easily shared and reused across different projects or packages. This promotes code consistency and reduces duplication.
- Simplified Dependency Management: In a monorepo, all projects or packages share the same set of dependencies. This eliminates version conflicts and makes it easier to manage and update dependencies.
- Improved Collaboration: Having all the code in one repository fosters better collaboration among developers. It allows for easier code reviews, sharing of knowledge, and encourages cross-team collaboration.
- Streamlined Continuous Integration/Continuous Delivery (CI/CD): With a monorepo, CI/CD pipelines can be set up to build, test, and deploy all projects or packages together. This ensures that changes in one project do not break the overall system.
- Simplified Versioning: In a monorepo, versioning can be done at the repository level, making it simpler to track and manage versions across different projects or packages.
Overall, monorepos provide a centralized and efficient approach to managing multiple projects or packages within a single repository. It is particularly common to use a monorepo when different packages or projects are integral parts of the same application. In our case, we are going to place the frontend and backend of the Posts app in a monorepo.
Step 1: Create a Root Folder and Copy the Source Codes
Follow these steps:
- Create a folder called
posts-monorepo
. - Inside the
posts-monorepo
folder, create two subfolders namedapp
andapi
. - Copy the React app that we have developed in the
posts-app
folder to theposts-monorepo/app
subfolder. When copying, make sure not to include thenode_modules
,.git
,.github
,.vscode
, ordist
folders. - Next, copy the Nest.js app that we have developed in the
posts-api
folder to theposts-monorepo/api
subfolder. When copying, make sure not to include thenode_modules
,.git
,.vscode
, ordist
folders.
Step 2: Add a Unified .gitignore
Notice that there is a .gitignore
file in each of the app
and api
subfolders. Let's delete those files. Instead, we will create a new .gitignore
file in the root folder that combines the content of both files:
# General dependencies
**/node_modules/
**/.pnp
**/.pnp.js
# Build outputs
**/out/
**/build/
**/dist/
**/dist-ssr/
# Testing
**/coverage/
**/.nyc_output
# TypeScript
**/*.tsbuildinfo
**/next-env.d.ts
# Environment and config files
*.pem
**/.env*.local
.env
.env.dev
.env.prod
.env.test
# Logs
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
**/*.log
**/logs
# OS files
.DS_Store
# Editor configurations and project files
**/.idea
**/.project
**/.classpath
**/.c9/
**/*.launch
**/.settings/
**/*.sublime-workspace
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
Note: The **/
notation is a glob pattern used in the .gitignore
file. It represents a recursive wildcard that matches any directory.
For example, the pattern **/node_modules
matches the node_modules
directory and all its instances in the subdirectories of the repository. In this case, it would match the node_modules
directory in both the app
and api
subfolders of the posts-monorepo
root folder.
Step 3: Vetting the .env
Files
We do not use a unified .env
file at the root level, although you may see this in some monorepos. Instead, each application has its own .env
file placed in its respective subfolder. It's worth mentioning that we haven't used .env
for the frontend React app yet, but we have used it for the Nest.js app.
First, make sure you have copied your .env
file for the Nest.js app to the api
folder. For your reference, these variables must be defined in that .env
file:
NODE_ENV=
DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=
JWT_SECRET=
JWT_EXPIRATION=
To specify the URL of the frontend React application, we will add another variable called CLIENT_URL
to the .env
file. As a reminder, our React app is bootstrapped with Vite and it runs on http://localhost:5173
. Therefore, you can add this URL as the value for CLIENT_URL
in your .env
file:
CLIENT_URL=http://localhost:5173
Next, create a .env
file inside the app
folder, and include the following content:
NODE_ENV=development
VITE_API_URL=http://localhost:3000
In the .env
file of the React app (app
folder), the VITE_
prefix is added to VITE_API_URL
. This is because Vite, the build tool used for the React app, requires variables to be prefixed with VITE_
in order to be accessible during the build process. This convention is specific to Vite and helps distinguish between environment variables used by Vite and other variables used within the React app.
Notice that in the .gitignore
file, we exclude the .env
file (and its variations) where we store our environment variables. While we do not want to commit the .env
file into our version control system, it is a good practice to provide an example of it for other developers to set it up accordingly. This example can be provided in the software documentation, in the repository README, or more commonly, in a .env.example
file.
Let's add a .env.example
file to the app
folder:
NODE_ENV=
VITE_API_URL=
Also, add a .env.example
file to the api
folder:
NODE_ENV=
DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=
JWT_SECRET=
JWT_EXPIRATION=
CLIENT_URL=
With this in place, we will next update our README files to draw developers' attention to these .env.example
files.
Step 4: Add a Unified README.md
Delete the README.md
files in the app
and api
folders. Instead, add the following README
to the root folder:
# Posts Monorepo
This is a monorepo that contains the client and server applications for the Posts project.
- The client is a React app bootstrapped with Vite.
- The server is a Nest.js application.
## Posts - A Simplified Social Media Platform
Posts is a minimalistic clone of <https://posts.cv/>, which is itself a simplified and scaled-down alternative to X (formerly known as Twitter). We built this clone solely for educational purposes. The following features are planned (and some are already implemented):
- User authentication
- Ability to create text, image, or combined posts
- Ability to follow other users
- Ability to like and comment on posts
- "Highlights" tab: posts from those you follow
- "Everyone" tab: all posts in reverse chronological order
## Getting Started
To get started with this project, follow these steps:
1. **Prerequisites**: Make sure you have Git, Node, and PNPM (the new package manager for Node) installed. If you don't have PNPM, you can install it globally with `npm install -g pnpm`. Additionally, you need to have Docker set up and running to spin up a local Postgres server. If you're new to Docker, you can refer to [this helpful guide](https://docs.docker.com/get-started/).
2. **Repository Setup**: Clone the repository and navigate to the root folder in the terminal.
3. **Dependencies**: Run `pnpm install` to install the dependencies for both the client and server.
4. **Environment Configuration**: Add a `.env` file in each the `app` and `api` sub-folders, similar to their respective `.env.example` files, and fill in the required environment variables.
5. **Database Setup**: Run `pnpm docker:up` to initialize the Postgres server.
6. **Run Locally**: To start the server, run `pnpm start:api`. To start the client, run `pnpm start:app`. Alternatively, you can run `pnpm start:all` to start both the client and server applications.
Notice that the instructions refer to running pnpm install
in the root folder to install the dependencies. This brings us to the next step, which is about PNPM workspaces!
Step 5: Create a PNPM Workspace
In this step, we will set up a PNPM workspace, which is a feature built into PNPM to support monorepos. With a workspace, you can unite multiple projects inside a single repository.
PNPM Workspace
PNPM workspace allows you to manage a monorepo, where each project is considered a "package" within a single repository. This approach offers several advantages, including:
- Unified Dependencies: PNPM workspace allows all packages within the monorepo to share the same set of dependencies. This eliminates duplication of dependencies and reduces the overall size of the project.
- Simplified Development: PNPM workspace provides a unified command-line interface to manage all packages. You can run scripts, install dependencies, and perform other tasks across multiple packages with a single command.
- Efficient Development Workflow: PNPM workspace enables you to easily make changes to multiple packages and test them together. This streamlines the development process and improves productivity.
Set up PNPM Workspace
To set up PNPM workspace in our monorepo, follow these steps:
-
Delete the
pnpm-lock.yaml
files in theapp
andapi
folders. -
Add a
pnpm-workspace.yaml
file to the root folder with the following content:packages: - 'api/**' - 'app/**'
The
pnpm-workspace.yaml
file is a key component to configure PNPM workspace in a monorepo. In this specific case, the file is defining the workspace packages by specifying the directories where the packages are located. -
Let's clean up the
package.json
file in theapi
folder:{ "name": "posts-api", "version": "0.0.1", "private": true, "license": "UNLICENSED", "scripts": { ... }, "dependencies": { ... }, "devDependencies": { ... }, "jest": { ... } }
Note that we are not making any changes to the
scripts
,dependencies
,devDependencies
, andjest
configurations. Simply make sure thename
,version
,private
andlicense
attributes are correct, and no other attributes are defined. -
Let's clean up the
package.json
file in theapp
folder:{ "name": "posts-app", "version": "0.0.1", "private": true, "license": "UNLICENSED", "type": "module", "scripts": { ... }, "dependencies": { ... }, "devDependencies": { ... } }
Note that we are not making any changes to the
scripts
,dependencies
, anddevDependencies
configurations. Simply make sure thename
,version
,private
,license
, andtype
attributes are correct, and no other attributes are defined. -
First, add the following
package.json
to the root folder:{ "name": "posts", "scripts": { "docker:up": "pnpm --filter posts-api run docker:up", "start:api": "pnpm --filter posts-api run start:dev", "start:app": "pnpm --filter posts-app run dev", "start:all": "concurrently \"pnpm run start:app\" \"pnpm run start:api\"" }, "devDependencies": { "concurrently": "^8.2.2" } }
This
package.json
file in the root folder of the monorepo serves as the main configuration file for managing the entire project.The
--filter
flag is used with thepnpm
command to specify the package or workspace to target. In this case, it is used to run specific scripts in either theposts-app
(React app) or theposts-api
(Nest.js app) package. These names correspond to the names declared in thepackage.json
file of each package (app and api).For example, the
start:app
script is defined aspnpm --filter posts-app run dev
. This means that when you runpnpm run start:app
, it will execute thedev
script in theposts-app
package as defined inapp/package.json
.Similarly, the
start:api
script is defined aspnpm --filter posts-api run start:dev
. This means that when you runpnpm run start:api
, it will execute thestart:dev
script in theposts-api
package as defined inapi/package.json
.The
start:all
script uses theconcurrently
package, which allows you to run multiple commands concurrently. In this case, it is used to run both the React app and the Nest.js app simultaneously. When you runpnpm run start:all
, it will execute both thestart:app
andstart:api
scripts concurrently.Please note that the provided
package.json
assumes the presence of theconcurrently
package as a devDependency. -
Next, open the terminal and ensure that your working directory is the root of the project,
posts-monorepo
. Then, runpnpm install
to install the dependencies for both the frontend (app) and backend (api) servers. -
Once the installation is complete, you will find a
pnpm-lock.yaml
file in the root folder (but not in theapp
orapi
subdirectories). Additionally, there will be anode_modules
folder in the root directory, as well asapp
andapi
subfolders. The one in the root folder contains the shared dependencies, while the ones inside the subfolders contain the dependencies specific to each package (app and api).
Step 6: Add a Unified Prettier
Currently, the package.json
file at the root folder provides a minimal configuration for managing the entire project. Depending on the nature of the project, you may choose to keep this configuration minimal or expand upon it.
On one hand, having a unified configuration promotes consistency and simplifies the management of the entire workspace. On the other hand, keeping the configurations separate allows for modularity in the codebase, making it easier to maintain and update each of the app
and api
packages in the future.
For example, both the app
and api
projects utilize Typescript and Eslint. Each project has the necessary packages installed as dependencies and provides separate configurations. While it may seem convenient to unify these configurations in the root folder, it is not advisable. The frontend (app) and backend (api) have different linting rules and TypeScript compiler options. Combining these configurations at the root level can result in conflicts and inconsistencies between the two projects.
💡 It is generally recommended to keep ESLint and TypeScript configurations separate for each project within a monorepo. This approach allows for better flexibility, modularity, and maintainability of the codebase.
Similarly, both the app
and api
projects use Prettier for code formatting. Each project has Prettier as a dependency and defines its own configuration. To maintain a consistent code style across all packages, it is more sensible to establish a unified Prettier configuration. By having a single Prettier configuration at the root level, we ensure that both the frontend and backend code adhere to the same formatting standards.
Let's set up a unified Prettier configuration:
-
Open the terminal and navigate to the
api
folder. Then, execute the following commands to remove Prettier as a dependency and its configuration file(s):rm .prettierrc pnpm remove prettier
-
Next, navigate to the
app
folder in the terminal and run these commands to remove Prettier as a dependency and its configuration file(s):rm .prettierignore .prettierrc.json pnpm remove prettier
-
Open the
api/package.json
file and remove the following line from it:"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
-
Open the
app/package.json
file and remove the following line from it:"prettier": "prettier --write \"src/**/*.{ts,tsx}\" --config \".prettierrc.json\"",
-
In the root of your monorepo (
posts-monorepo
), create a.prettierrc
file with the following content:{ "semi": true, "trailingComma": "all", "singleQuote": false, "printWidth": 80, "tabWidth": 2, "endOfLine": "auto" }
This configuration sets the rules for code formatting, such as the usage of semicolons, trailing commas, quotes, etc.
-
Modify the
package.json
file in the root folder to include commands for formatting:"scripts": { "format:write": "prettier --write \"**/{src,test}/**/*.{ts,tsx}\" --config \".prettierrc\" --cache", "format:check": "prettier --check \"**/{src,test}/**/*.{ts,tsx}\" --config \".prettierrc\" --cache", // other scripts... }
These commands will format and check all TypeScript files in the
src
andtest
directories of both theapp
andapi
packages. -
Then, install Prettier at the root level of your monorepo. Open the terminal at the root folder and execute the following command:
pnpm add prettier --save-dev --workspace-root
-
To format your code, run
pnpm run format:write
from the root directory. -
To check if the files are formatted correctly, run
pnpm run format:check
.
By following these steps, you'll have a unified code formatting setup across your entire monorepo, ensuring that all code changes, whether in the frontend or backend, adhere to a consistent style. This setup enhances code readability and quality, making it easier for the team to maintain and contribute to the project.
Step 7: Running the React App and the API Server
To ensure the React and Nest.js apps are functioning correctly, follow these steps to run them. Since we have not yet integrated these applications, we will run them separately and verify their proper functionality. This step is crucial because changes have been made to the project structure of each app, and certain settings or configurations may need to be updated.
-
First, run the docker container of the Postgres database (if it is not already running) by executing the
pnpm docker:up
command. -
Then, run the backend by executing the
pnpm start:api
command. Open Postman and test all the endpoints and available operations, ensuring that the protected routes are secured. -
Next, run the frontend by executing the
pnpm start:app
command. Open the browser and visit http://localhost:5173/ to verify that the app works as expected. If a specificbase
attribute has been added in thevite.config.ts
file to deploy the app on GitHub, make sure to change it back to the default configuration provided below:import path from "path"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ base: "/", plugins: [react()], resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, });
Once the app is running, you should be able to add and delete posts. Open the browser console and ensure that no errors or debugging messages are printed. Also, keep an eye on the VSCode terminal to ensure there are no errors or issues while working with the app.
-
Finally, stop both applications and run them concurrently one last time using the
pnpm start:all
command.
If all goes well, you are ready for the next step!
Step 8: Add .vscode
Folder
Let's configure the debugging for both the frontend and backend applications. Start by adding a .vscode
folder to the root folder (posts-monorepo
) of your monorepo. Then, create a launch.json
file inside the .vscode
folder with the following content:
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:5173/",
"webRoot": "${workspaceFolder}/app/src",
"sourceMapPathOverrides": {
"webpack:///src/*": "${webRoot}/*"
}
},
{
"type": "node",
"request": "launch",
"name": "NestJS Debug",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/api/src/main.ts",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "posts-api", "run", "start:debug"],
"outFiles": ["${workspaceFolder}/api/dist/**/*.js"]
}
]
}
These configurations allow you to debug the frontend (app) and backend (api) packages separately.
To debug the frontend, first run the application using either pnpm start:all
or pnpm start:app
if the backend is not required. Then, in VSCode, go to the Debug view by clicking on the Debug icon in the Activity Bar on the side of the window. Select Launch Chrome against localhost
from the dropdown at the top of the Debug view. Click on the green play (▶️) button to start debugging. A new Chrome instance with debugging enabled should open. You can set breakpoints in your React code (app
folder) within VSCode. When the code execution reaches those points, it will pause, allowing you to inspect variables, the call stack, and more.
To debug the backend, stop the application and let the debugger run it. In VSCode, go to the Debug view by clicking on the Debug icon in the Activity Bar. Select NestJS Debug
from the dropdown at the top of the Debug view. Click on the green play (▶️) button to start debugging. VSCode will run the backend in debug mode by executing pnpm --filter posts-api run start:debug
. You can open up Postman and make a request. You can set breakpoints in your Nest.js code (api
folder) within VSCode. When the code execution reaches those points, it will pause, allowing you to inspect variables, the call stack, and more. If you want to work with the frontend while debugging the backend, open a new terminal and run pnpm start:app
to run the frontend while the backend is running in debug mode.
The .vscode
folder contains configuration files for Visual Studio Code. More files can be added to ensure a standardized development experience across a team of developers.
For instance, you can add an extensions.json
file with the following content:
{
"recommendations": [
"ms-vscode.js-debug-nightly",
"dbaeumer.vscode-eslint",
"mhutchie.git-graph",
"heybourn.headwind",
"esbenp.prettier-vscode",
"kalimahapps.tailwind-config-viewer",
"bradlc.vscode-tailwindcss",
"austenc.tailwind-docs"
]
}
This file lists recommended extensions for VS Code that can enhance the development experience. It includes extensions for debugging, ESLint integration, Git visualization, Tailwind CSS support, and more. Including this file ensures that all team members have access to helpful tools and maintain a consistent coding environment.
Another example is the settings.json
file, which can be added with the following content:
{
"window.autoDetectColorScheme": true,
"workbench.preferredLightColorTheme": "Default Light+",
"workbench.preferredDarkColorTheme": "Default Dark+",
"workbench.colorTheme": "Default Light+"
}
This file contains editor settings such as color schemes and other preferences.
While it is beneficial to standardize certain settings, individual developers may have different preferences for their local development environment. To avoid conflicts and allow personal customization, you may consider excluding the settings.json
file from version control. This can be achieved by removing the !
from !.vscode/settings.json
in the .gitignore
file.
Step 9: Initialize a Git Repository
As a final step, you need to initialize a Git repository in the root folder, posts-monorepo
:
git init -b main
git add .
git commit -m "Initial Commit"
Please note that we haven't set up any deployment strategies for the frontend or backend applications yet. We will address that in the future. For now, our focus will be on integrating the backend and frontend.