Doğacan Bilgili A software developer who is also into 3D-modeling and animation.

Deploying a decoupled monorepo project on Heroku

5 min read 1550

Heroku Logo Over a Landscape Background

What is a monorepo?

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.

Building a decoupled monorepo project

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.

Deploying a monorepo to Heroku

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.

Building the client app with Vite

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.

We made a custom demo for .
No really. Click here to check it out.

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'
    }
  }
})

Building the server app

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.

Testing the server

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"
},

Conclusion

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.

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Doğacan Bilgili A software developer who is also into 3D-modeling and animation.

Leave a Reply