The goal of a monolith is to have a single codebase for an entire project, while a decoupled project means that the functionalities are not dependent on one another.
When a codebase is set up as a monorepo, it becomes easy to see how the entire project is structured, especially if there are multiple projects, such as multiple frontends or several microservices.
Furthermore, it becomes easy to share code between each project. For example, if there are multiple frontends, they might share UI components easily, as the whole codebase sits in the same repo. So, using monorepos makes managing your project easier and provides a better development experience.
When a project is decoupled and has several codebases for each functionality, it is best practice to have separate package.json
files for each system so they can be easily moved and potentially integrated into another project if necessary.
Although it is possible to have a single package.json
as the source of truth for the package, this approach is not scalable and likely to get cluttered quickly. However, having a single package.json
file can also enable dependency sharing.
There are tools for managing and leveraging such monorepo projects, such as Lerna, a tool to manage multiple projects in a single repository. Lerna can help developers have common dependencies under the root directory and manage the specific dependencies under specific folders for each project. This makes the dependency management easier, as the shared dependencies are controlled from one file.
In this article, we are going to deploy a basic monorepo to Heroku, which has a client and a server application.
We’ll use TypeScript on both applications and control the build processes of each application through a package.json
file in the root directory of the project. This file is the one detected by Heroku containing the scripts to control the separate package.json
files belonging to client and server applications.
The goal is to compile the TypeScript code and build the client application, compile the server application with TypeScript, and then make it serve the distribution of the client application. We will also implement a simple REST API endpoint to demonstrate the connection between client and server both in development and production.
Create a folder, then, inside that folder, run npm init -y
to generate a package.json
file. Next, create two separate folders for the client and server. For the client application, let’s use Vite, which is a build tool supporting React, Vue, and Svelte.
Vite serves your code for development and bundles it for production. It uses ESLint under the hood and supports hot module replacement, which helps you see the changes in your code while developing without losing the state of the application.
To create a frontend application with Vite, use the following command where client
is the name of the project and the folder:
npm init vite client
After running the command, you will be prompted to choose a framework. I chose React and react-ts as the variant, which comes as the follow-up prompt.
Now our project folder has a package.json
file and a client
folder. Before moving further, go into the client
folder and run npm install
to install all the packages.
We need to configure the proxy setting in the vite.config.ts
file. If we want to make a request to the server application, we can configure the proxy setting as localhost:8080
, where 8080
is the port number we are going to use.
This way, we can make a request to /api/test
in the client application, and that would be sent to localhost:8080/api/test
, for example. This is only for development, given that both applications will be served from the same origin in production.
Update the vite.config.ts
file so that it contains the server
object, as follows:
export default defineConfig({ plugins: [react()], server: { proxy: { '/api': 'http://localhost:8080' } } })
Let’s now create a server folder to store the files for our server. Inside it, run npm init -y
to generate a package.json
file.
Because we used React with TypeScript, it would be a good practice to use TypeScript for the server application, too.
Inside the server
folder, run npx tsc --init
to generate a configuration file for TypeScript. The generated file comes with several options set by default, but we are going to add extra parameters to tailor it for our needs.
The configuration file generates the compiled .ts
files inside the ./dist
folder, and, by setting rootDir
key to ./src
, we ensure that the content of ./src
will appear directly under ./dist
when compiled.
{ "compilerOptions": { "target": "es6", "module": "commonjs", "outDir": "./dist", "rootDir": "./src", "strict": true, "moduleResolution": "node", "esModuleInterop": true, "allowSyntheticDefaultImports": true }, "exclude":[ "./node_modules" ] }
Next, let’s install the required dependencies. We need typescript
, @types/node
, @types/express
and ts-node-dev
as the dev dependencies, as well as express
as a dependency, which is the framework we are going to use to serve the client application and create endpoints.
npm instal --save-dev typescript ts-node-dev @types/node @types/express npm install --save express
ts-node-dev is a package for watching changes in Node.js written in TypeScript. It’s basically a nodemon
equivalent for TypeScript with Node.
Now we can edit the package.json
file to add scripts to build and run the project for development. Add the following scripts to the package.json
file:
"scripts": { "build": "tsc --build", "dev": "ts-node-dev --respawn ./src/index.ts" },
The last file we need is the .gitignore
file to ignore node_modules
. Create a .gitignore
file with the following content:
node_modules
We didn’t need this with the client application, as the boilerplate created by Vite already has a .gitignore
file.
So far, we have completed setting up both the client and server applications. Now we are going to write a small server with an endpoint as a use case.
Under /server/src
, create an index.ts
file that has the following content:
import express from 'express'; import path from 'path'; const app = express(); const PORT = process.env.PORT || 8080; const pathName = path.join(__dirname, '/../../client/dist'); app .use(express.static(pathName)) .listen(PORT, () => console.log(`Listening on ${PORT}`)); app.get('/api/test', (req, res) => { res.send({ foo: 'bar' }); }); app.get('*', (req, res) => { res.sendFile(pathName); });
This is a basic Express server running on port 8080
and serving what is inside the client/dist
folder, which is the directory containing the output of the build process from the client application.
We also have an endpoint accessible on /api/test
, which responds with an object for the test purpose.
Now we can quickly test the server application by sending a request from the client. Vite generates an example application, so we can use this to create a function and a GET request to the server, then call that function on component mount.
Under client/src
, find App.tsx
and add the following snippet:
const get = async () => { const res = await fetch('/api/test'); const body = await res.json() console.log(body) } useEffect(() => { get(); })
Before we run the development server for the client application, we should start the server application so that the /api/test
endpoint is accessible. Under /server
directory, run npm run dev
to start the server in watch mode.
Now run the development server for the client application by using the npm run dev
command under /client
directory. This will start a development server on localhost:3000
. If you visit the page and open the browser console, you should see the object returned from the server.
In order to deploy these two applications to a single Heroku dyno, we need to add some scripts to the package.json
in the main project directory.
|- server |- client |- package.json
Because we have multiple folders with their own package.json
files, we should tell Heroku to install the dependencies, along with devDependencies
, inside these folders. To do so, go into these directories and call npm install --dev
. The reason we need devDependencies
is that we need to compile the TypeScript with typescript
package, which is listed in the devDependencies
.
The same applies to the build process. We go into these folders and call the npm run build
command. Finally, we need to start the application, which is only the server application.
"scripts": { "install": "cd client && npm install --dev && cd ../server && npm install --dev", "build": "cd client && npm run build && cd ../server && npm run build", "start": "cd server/dist && node index.js" },
In this article, we discussed how to deploy a decoupled monorepo project to a single dyno on Heroku instead of having multiple dynos for a server and a client application. In the case of having multiple microservices, along with client and server applications, you’ll need multiple dynos, as each service should run on its own.
In the example of a full-stack application without any additional services, it is only the server running on a dyno to serve the client and possibly enable the communication between the client and possible microservices.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowReact Native’s New Architecture offers significant performance advantages. In this article, you’ll explore synchronous and asynchronous rendering in React Native through practical use cases.
Build scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.