Monorepo. Building one roof for your UI apps.

Monorepo. Building one roof for your UI apps.

Featured on Hashnode

The Beginning:

Hi there,

I recently merged all the repos (even express backend server code) into a single repository and wanted to share my learnings with code samples with you all...

Prerequisites: You should have coffee or tea or even beer in your vicinity.

Wait, Why Monorepo?

Monorepos are there in existence from many years - the reasons for any frontend/web team to opt for monorepo maybe different but boils down to mainly these 3 points:

  1. Re-usability of common code, configurations, components, etc.
  2. Ease of new project setup:
    • [Basic configurations] Should have all the configs like eslint, prettier, .vscode settings, husky + lint-staged, tsconfig etc. should be done in under ~1 minute
    • [Advance configurations] Tools like jest config, MSW and tailwindcss setup should be done in under ~5 minutes
  3. Better Developer Experience
    • No explicit yarn linking of different packages OR switching between repos while doing development.
    • Ease in team workflow: Merge all your changes related to your task in 1 go, instead of merging each PR for different repo separately (This maybe a real pain in your team too - let's say express backend code might merge before, causing the UI code to break for other team mates until that developer's UI code is also merged).

Aha! Monorepo makes sense. But how can I easily have a Monorepo setup?

I guess you would have all guessed from the title that this article is going to be about - Monorepo with turborepo

There are many tools available in the market, listing the most popular ones here:

  1. Bazel
  2. Nx
  3. Gradle
  4. Pants
  5. Lerna
  6. Turborepo
  7. Rush

Yes there are many tools and you should choose the tool which best suits your requirement. Here's an image from this excellent website - monorepo.tools which extensively compares all these tools in depth.

Screenshot 2022-06-05 at 2.12.59 PM.png

Why Turborepo?

Main selling point is the integration - it is very simple and easy to understand especially for a new person joining your team - the various apps workflows and their inter-dependencies can be understood easily. Also Turborepo has a great community support and many amazing things seems to be lined up in this project by the Vercel team.

Screenshot 2022-06-05 at 2.24.23 PM.png

What more?

  1. Turborepo easily finds the pruned subset of your monorepo to quickly build only your target web app without installing modules of other web apps.

    Jump to last section to see how to make Dockerfile using turbo prune command.

  2. More Control - You can only build the web app that has code changes without building any of it's dependent. Or you can choose to build the dependents as well. More details on --no-deps here.

Okay, finally the setup:

We will take a look at the basic project structure first then cover following topics:

  1. Root package.json
  2. Adding Basic configurations: eslint, prettier, lint-staged + husky, tsconfig
  3. Adding Advance configurations: tailwindcss, jest
  4. turbo.json to add workflow settings
  5. Create Dockerfile using turbo prune

Basic project structure:

Let's have look at the folder structure: Screenshot 2022-06-05 at 3.06.51 PM.png

Let's take a closer look at /apps folder:

  • It contains 2 apps: frontend-app-1 and frontend-app-2 powered by NextJS.

Screenshot 2022-06-05 at 3.14.28 PM.png

Let's take a closer look at /packages folder:

  • It contains all the pluggable config packages that can be easily added to frontend-app-1 and frontend-app-2

Screenshot 2022-06-05 at 3.12.16 PM.png


1. Root package.json:

Root package.json mainly contains:

  • workspaces: regex for yarn workspaces to find your various apps and packages
  • Scripts to turbo charge your project, turbo will look for package.json of frontend-app-1 and frontend-app-2 and execute the scripts with same name in their package.json (if present) in the most optimised way possible.
...
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint-staged": "turbo run lint-staged --concurrency=1",
    "test": "turbo run test --parallel",
    "test:cov": "turbo run test:cov --parallel",
    "tsc": "turbo run tsc --parallel",
    "start:prod": "turbo run start:prod",
  },
...

2. Adding Basic configurations:

Screenshot 2022-06-05 at 3.20.18 PM.png

  • Create a packages/config folder, which will have configs for eslint, lint-staged and prettier
  • The packages/config/package.json is required to make it a package which can be added to package.json's of frontend-app-1 and frontend-app-2
  • packages/config/package.json file looks like:
{
  "name": "config",
  "version": "0.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "@typescript-eslint/eslint-plugin": "^5.25.0",
    "eslint": "^8.15.0",
    "eslint-config-next": "^11.1.2",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^4.0.0",
    "prettier": "^2.4.1",
    "lint-staged": "^11.2.0",
    "husky": "^7.0.2",
    "path": "^0.12.7"
  }
}
  • packages/config/eslint-preset.js file looks like:
module.exports = {
  extends: [
    'next',
    'next/core-web-vitals',
    'plugin:@typescript-eslint/recommended',
    'prettier',
  ],
  plugins: ['@typescript-eslint'],
  rules: {
    'prefer-const': 'error',
    '@typescript-eslint/ban-ts-comment': 'off',
    '@typescript-eslint/ban-types': 'warn',
    'react/display-name': 'off',
    '@typescript-eslint/no-var-requires': 'off',
    'no-console': 'error',
    '@typescript-eslint/no-unused-vars': 'error',
  },
};
  • Finally, we have our first independent package ready, let's add config package to package.json's of frontend-app-1 and frontend-app-2
  • Add following line to apps/frontend-app-1/package.json and apps/frontend-app-2/package.json:
"devDependencies": {
    "config": "*",
     ...
}

NOTE 1: yarn workplaces has a concept of hoisting, where common packages between 2 sibling apps are hoisted to their parent's node_module.

NOTE 2: when searching for a package in a app, first app's own node_module is searched then it's parent, then parent's parent and so on..

  • Last but not the least, let's make .eslintrc.js in frontend-app-1 and frontend-app-2 with following code:
module.exports = require("config/eslint-preset");

lint-staged and prettier setup can be done exactly the same way !!


3. Advance configurations:

  • Let's start with adding tailwindcss to both frontend apps:
  • Step 1 is to create a new package under /packages folder called tailwind-config
  • Let's add tailwind.config.js, postcss.config.js and styles folder (see tailwind setup docs for more details) in tailwind-config folder
  • Create a package.json in packages/tailwind-config:
{
  "name": "tailwind-config",
  "version": "0.0.0",
  "main": "index.js",
  "license": "MIT",
  "files": [
    "tailwind.config.js",
    "postcss.config.js"
  ]
}
  • Add tailwind to root package.json as tailwind is used by both frontend apps:
  "devDependencies": {
    "postcss": "^8.4.5",
    "postcss-import": "^14.0.2",
    "postcss-nested": "^5.0.6",
    "tailwindcss": "^3.0.2"
}
  • You are all set now to add tailwind to your projects now !!
  • Add following code to apps/frontend-app-1/package.json and apps/frontend-app-2/package.json:
  "devDependencies": {
    "tailwind-config": "*" 
}
  • Create and add following code to apps/frontend-app-1/tailwind.config.js and apps/frontend-app-2/tailwind.config.js:
module.exports = require('tailwind-config/tailwind.config');
  • Create and add following code to apps/frontend-app-1/postcss.config.js and apps/frontend-app-2/postcss.config.js:
module.exports = require("tailwind-config/postcss.config");
  • Add Tailwind styles in apps/frontend-app-1/styles/index.css and apps/frontend-app-2/styles/index.css:
@import 'tailwind-config/styles/index.css';

That's it - now both frontend apps are powered by tailwind css.

NOTE: TailwindCSS autosuggestion will not work now as there are multiple tailwind.config.js files in the repo. To make it work in VS code editor, add these lines to .vscode/settings.json:

  "editor.quickSuggestions": {
    "strings": true
  },
  "tailwindCSS.experimental.configFile": {
    "packages/tailwind-config/tailwind.config.js": "**"
  }

4. Time to turbocharge via turbo.json file:

  • This file is used by Turborepo to create a pipeline.
  • Depending upon your need, you can define the dependency graph of your monorep like what package to build first, what package server to start first OR what task to execute inside a package and it's dependent. Example: build task in your package may depend on test, tsc and formatting/prettier tasks. Another Example: you may want to deploy your app-1 first then only deploy your app-2.

  • Turbo interprets this configuration to optimally schedule, execute, and cache the outputs of each of the package.json scripts defined in your workspaces. More here.

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"],
      "cache": true
    },
    "lint-staged": {
      "outputs": [],
      "cache": false
    },
    "frontend-app-1#build": {
      "dependsOn": ["frontend-app-2#build"],
      "outputs": []
    },
    "dev": {
      "cache": false
    },
    "test:cov": {
      "outputs": []
    },
    "start:prod": {
      "outputs": []
    }
  }
}

See Caching in Action:

  1. Build you project once via yarn build:

Screenshot 2022-06-05 at 1.23.46 PM.png

  1. Build you project the second time (with no code changes after step1):

Screenshot 2022-06-05 at 4.43.49 PM.png

Dockerfile with turbo prune:

Prerequisite: Refill your tea/coffee/beer glasses if empty

Now the most interesting part - how do we prune all the unwanted packages and keep which is only relevant to the target app - let's say frontend-app-1?

Here's the Dockerfile in action, we will go through each stage in detail:

# Stage 1
FROM node:16-alpine AS deps
WORKDIR /app
RUN yarn global add turbo@1.2.9
COPY . ./
RUN turbo prune --scope=frontend-app-1 --docker

# Stage 2
FROM node:16-alpine AS installer
WORKDIR /app
COPY --from=deps /app/out/json/ .
COPY --from=deps /app/out/yarn.lock ./yarn.lock
RUN yarn install --frozen-lockfile 

# Stage 3
FROM node:16-alpine AS sourcer
WORKDIR /app
ENV NODE_ENV production
COPY --from=installer /app/ .
COPY --from=deps /app/out/full/ .
RUN yarn turbo run build --scope=frontend-app-1 --include-dependencies --no-deps

# Stage 4
FROM node:16-alpine AS final
WORKDIR /app
ENV NODE_ENV production
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
COPY --from=sourcer /app/apps/frontend-app-1/next.config.js ./
COPY --from=sourcer /app/apps/frontend-app-1/.env.production ./.env.production
COPY --from=sourcer /app/apps/frontend-app-1/public ./public
COPY --from=sourcer /app/apps/frontend-app-1/images ./images
COPY --from=sourcer /app/packages ./packages
COPY --from=sourcer --chown=nextjs:nodejs /app/apps/frontend-app-1/.next ./.next
COPY --from=sourcer /app/node_modules ./node_modules

USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node_modules/.bin/next", "start"]

Stage-1

  • This stage globally installs turbo and using it's prune command - prunes all unwanted packages and outputs a subset of your monorepo with only frontend-app-1 and it's dependent packages!

Example of pruned monorepo:

Screenshot 2022-06-05 at 4.45.56 PM.png

out/json folder looks like:

Screenshot 2022-06-05 at 4.51.07 PM.png

out/full contains the same structure as out/json folder but with full code !!

Stage-2

  • This stage copies only the content of out/json folder with newly generated pruned yarn.lock and does yarn install.
  • As package.json does not changes frequently, it makes more sense to copy only the out/json folder which has only package.json of apps and packages folder. This stage is then cached and will only re-run freshly if any of the package.json is changed.
  • If instead you copy out/full folder, then this stage will be executed everytime (as your code files will have new changes).

    Each command that is found in a Dockerfile creates a new layer. Each layer contains the filesystem changes to the image for the state before the execution of the command and the state after the execution of the command. If the file system changes are present then cached layer is ignored!

Stage-3

  • Now from stage-2, you got your node_modules required for building your app frontend-app-1
  • Copy the node_modules from stage-2 and the full code of your app from stage-1, and finally run turbo build to build your app.

Stage-4

  • Here we are mainly copying the files relevant for running the server
  • This is also done so that the docker image size is less

Thanks for reading !!

Alright! You have reached the end of this article. If you liked this article, kindly give likes and comment the best part you liked about this article.


Did you find this article valuable?

Support Sagarpreet Chadha by becoming a sponsor. Any amount is appreciated!