Setting up a Monorepo using PNPM workspaces with TypeScript and Tailwind

11 min read
Cover Image for Setting up a Monorepo using PNPM workspaces with TypeScript and Tailwind

In this article, We're going learn how to set up a monorepo and share multiple configs and dependencies between each package inside one repository. Before, we proceed, we have to understand what a monorepo is and what benefits it brings to the table.

What is a Monorepo?

A monorepo is a single repository containing multiple distinct projects, with well-defined relationships. It contains different applications or libraries which are somewhat dependent on each other but are logically independent

At this moment in time, the de facto way of developing applications is having a separate repo for each distinct project, library, or team. It is customary for each repository to have a single build artifact and its dedicated build pipeline. Nevertheless, the industry has gradually gravitated away from this practice, primarily for two significant reasons: ease of code reuse and desire for team autonomy.

Typically, the common process we reuse code is we publish it as a package to a remote registry like npm. Then, the app dependent on it installs it from the registry as an external dependency and uses the package as it sees fit. However, this approach poses multiple problems.

First, we have to set up package publishing and tooling with the CI environment for the package being shared. But then, we introduce another problem: inconsistent tooling. The shared package will now require its own set of commands for running tests, building, serving, linting, deploying, and so forth. I could go on to provide numerous examples to illustrate how much maintenance overhead and work this will lead to in the long run.

But this is where monorepo's shine. They can drastically enhance this workflow by eliminating the overhead of publishing versioned packages so that it can be used by other internal projects and eliminating the concept of a breaking change when everything is fixed in the same commit. Let's take a quick dive into how to set one up using PNPM workspaces.

Setup Root Level Project

The first thing that we need to do is set up our root project which will encapsulate and manage all our applications. We can do so by initializing a pnpm project in our desired folder.

mkdir 'pnpm-monorepo' && cd pnpm-monorepo && pnpm init

Now our root project has been initialized, we should be able to add necessary root-level dependencies and dotfile configs that we’ll use across our projects.

TypeScript

pnpm add -D typescript

We'll start by setting up TypeScript as our initial root-level dependency. Installing it at this level ensures consistency in versioning across all projects. Let's populate our base configuration with the following code:

tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "module": "ESNext",
    "skipLibCheck": true,
    "noImplicitAny": false,
    "allowJs": true,
    "noErrorTruncation": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,

    "declaration": true,
    "composite": true,
    "sourceMap": true,
    "declarationMap": true
  },
}

This will serve as the default tsc compiler config shared throughout our project. While it might seem daunting due to the multitude of configurable options, Matt Pocock has written a comprehensive piece explaining the purpose of each property if you're interested.

Following that, we'll create our tsconfig.node.json file which will be extended by our base config. This file serves a unique purpose, acting as a workaround for Vite to omit bundling node types to the browser. If you're utilizing a different framework, you could skip creating this file.

{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true
  }
}

Now let's look to set up linting and formatting. For these, we'll be using ESLint and Prettier.

Prettier

pnpm add -D prettier prettier-plugin-tailwindcss
echo '{"printWidth": 80, "singleQuote": false}' > .prettierrc.json
echo 'dist
node_modules
*/*.yml' > .prettierignore

For quality-of-life purposes, let's add a package script to format every project in our codebase.

pnpm pkg set scripts.format="prettier --write \"./**/*.{js,jsx,ts,tsx,json}\""

ESLint

pnpm create @eslint/config

The ESLint wizard will ask you a set of questions to set up the linter as per your needs. This is the configuration I've chosen for this project.

.eslint.cjs

module.exports = {
  env: { browser: true, es2021: true, },
  extends: [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:react/recommended",
  ],
  overrides: [
    {
      env: { node: true, },
      files: [".eslintrc.{js,cjs}"],
      parserOptions: { sourceType: "script", },
    },
  ],
  parser: "@typescript-eslint/parser",
  parserOptions: { ecmaVersion: "latest", sourceType: "module", },
  plugins: ["@typescript-eslint", "react"],
  rules: {},
};

By default, ESLint will look for configuration files in all parent folders up to the root directory. We don't want such behavior so we'll set the root property to true.

module.exports = {
  root: true,
  ...
}

Now, let's configure PNPM to use workspaces by adding a pnpm-workspace.yaml file. It serves the purpose of including/excluding directories to be added to our workspace using glob patterns.

pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"

The glob pattern here adds all direct subdirs of our specified folders. To add extra projects, we can do so by simply adding the absolute path of the project to our workspace file.

At this point, our repository has been set up to use workspaces. Now, let's proceed to create our individual projects.. Our applications will be housed under the apps/ folder and the shared projects will created under packages/.

Create a Project

For our applications, I'll be using Vite to initialize the projects. Now this can be done using any other JS framework like Next.js, Vue.js, Svelte, etc., and will remain still applicable.

First, let's create our apps and packages directory at the root.

mkdir apps packages

Now, let's init (for this article) our main application by changing our current directory and calling the bash script:

cd apps
pnpm create vite frontend --template react-ts
cd ../
pnpm install

PNPM has a filtering feature that allows you to restrict commands to specific subsets of packages using a filter tag (--filter). This will come in handy later on, so let's create one now.

pnpm -w exec -- pnpm pkg set scripts.frontend="pnpm --filter @monorepo/frontend"

PNPM follows a workspace naming convention, assigning child projects the format "@root-project-name/project-name". In our case, our root project name is "monorepo" so our child project package.json name would be "@monorepo/frontend". Let's proceed to make this adjustment in our package.json.

/packages/apps/frontend/package.json

{
"name": "@monorepo/frontend"
...
}

Let's extend our base tsconfig.json and tsconfig.node.json from our root folder.

/apps/frontend/tsconfig.json

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    // "noEmit": true,
    "baseUrl": ".",
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

Typically, we would enable "noEmit" in our config to prevent TypeScript compiler from generating transpiled code, as this task is managed by our bundler, Vite. However, this is not a concern thanks to our base config.

/apps/frontend/tsconfig.node.json

{
  "extends": "../../tsconfig.node.json",
  "include": ["vite.config.ts"]
}

We're done setting up our main application at this point. If you have any extra dotfile configurations, feel free to extend them too.

Create Shared Package

Let's create an extra project to share some code across our applications or even other shared packages. The project is going to be built as an external library with declaration files bundled together.

Let's create the project by running these commands from our root directory.

cd packages
pnpm create vite ui --template react-ts
cd ui/src
rm -rf assets App.tsx App.css ../.eslintrc.*
pnpm -w install
pnpm -w exec -- pnpm pkg set scripts.ui="pnpm --filter @monorepo/ui"

TIP: When executing commands for the root project while working within a child project, you can use the '--workspace-root' flag or its shorthand '-w' to prevent changing directories. For example, 'pnpm -w run frontend dev' will spin up the dev server for our frontend app while within the 'ui' working directory.

Let's change the name of our project to match naming previous naming convention and also extend our tsconfig.json and tsconfig.node.json.

/packages/apps/ui/package.json

{
"name": "@monorepo/ui"
...
}

/packages/apps/ui/tsconfig.json

{
  "extends": "../../tsconfig.json",
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

/packages/apps/ui/tsconfig.node.json

{
  "extends": "../../tsconfig.node.json",
  "include": ["vite.config.ts"]
}

By default, Vite builds our assets in app mode using index.html as the entry file. However, we intend to build our app in library mode and expose the main.ts file as the entry point for our package. Let's proceed to modify the Vite configuration to accommodate this. Also while we're at it, we'll install a Vite plugin to auto-generate declaration files for our library.

pnpm -w ui add -D vite-plugin-dts @types/node

/packages/ui/vite.config.ts

import { defineConfig } from "vite";
import { resolve } from "path";
import dts from "vite-plugin-dts";

// https://vitejs.dev/config/
export default defineConfig({
  build: {
    lib: { entry: resolve(__dirname, "src/main.ts"), formats: ["es"] },
  },
  plugins: [dts()],
});

With all this finally out of the way, let's create an example component.

/packages/ui/src/components/Button.tsx

import * as React from "react";

type ButtonProps = React.ComponentProps<"button"> & {
  children: React.ReactNode;
};

export const Button = ({ children, ...props }: ButtonProps) => {
  return <button {...props}>{children}</button>;
};

Let's now export it out of our main entry file.

/packages/ui/main.ts

export { Button } from "./components/Button";

We'll be finished creating our library once we update the package.json file with the entry file and type declarations.

{
 ...,
 "main": "dist/ui.js",
 "types": "dist/src/main.d.ts",
}

NOTE: The output name for 'main' property is based on your application, whereas the type declaration output name is derived from your entry filename.

We now have a basic functional component named Button that accepts all possible props a button could have, in addition to a children prop for displaying content.

Let's head back to our main application and import our ui package. First, we have to install the library under monorepo/@frontend using the workspace filter command:

pnpm -w frontend add @monorepo/ui

NOTE: By default, when we try to install a package, pnpm will always try to link the package from our workspace based on the declared range. This wasn't always the case before; if the range wasn't matched, it would install the package from the npm registry. With the "workspace:" protocol, pnpm will refuse to resolve to anything other than a local workspace package.

Inside the @frontend application, our package.json should look similar to this:

{
...
"dependencies": {
    "@monorepo/ui": "workspace:^",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
...
}

Lastly, let's build our @ui library. We can't import anything if there's no output artifact to begin with.

pnpm -w ui build

That's it, now we should be able to import our components or whatever is exported from the ui package.

If you remember, we created a button component in the ui package. Let's render that button inside our frontend application:

/apps/frontend/src/App.tsx

import { Button } from "@monorepo/ui";

const App = () => {
  return (
    <Button
      style={{
        backgroundColor: "blue",
        padding: "10px",
      }}
    >
      Hello World
    </Button>
  );
};
export default App;

If we spin up our dev server and visit our localhost in our browser, we'll see a blue button rendered, saying 'Hello World'."

Dev Mode

Everything works with the current setup but it's highly inefficient. There are currently two problems:

  1. We have to manually rebuild our dependency packages each time a change is made to them.

  2. We have to restart our server each time when the former is performed.

To address the initial problem, we'll establish a development script to build our ui package in watch mode.

/packages/ui/package.json

  "scripts": {
    "dev": "vite build --watch",
    ...
  },

Additionally, we'll create a root-level script to execute the "dev" script across all packages in our workspace recursively, as specified in their package.json.

/package.json

  "scripts": {
    "dev": "pnpm --recursive --parallel --stream run dev",
    ...
  },

TIP: To selectively run a script for a package and its dependencies only, suffix an ellipsis to the package name (<package_name>...) with the --filter flag. For instance, if building for production, use: pnpm --filter @monorepo/frontend... build. This will build the specified package (@monorepo/frontend) and also its dependencies in the workspace.

To fix the second issue, we're going to have to configure an alias in the vite.config.ts of the @frontend app.

export default defineConfig({
  ...,
  resolve: {
    alias: {
      "@monorepo/ui": path.resolve(__dirname, "../../packages/ui/src/main.ts"),
    },
  },
});

By adding our ui package as an alias, it forces Vite to hot reload whenever the build artifact changes.

Bonus: Add Tailwind Support

To establish a single source of truth for all Tailwind configurations, we'll create an additional project for this purpose under the "/packages" directory.

cd packages
mkdir tailwind && cd tailwind && pnpm init
touch tailwind.config.js postcss.config.js
pnpm -D install tailwindcss postcss autoprefixer

Update the project name to match previously used naming convention.
/packages/tailwind/package.json

{
"name": "@monorepo/tailwind"
...
}

Add the paths to all of our HTML templates, JS components, and any other files that contains Tailwind class names in the tailwind.config.js file. In order for Tailwind to generate all of the CSS we need, it needs to know about every single file in our project that contains any Tailwind class names.

/packages/tailwind/tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default = {
   content: [
    "./index.html",
    "../../packages/**/src/**/*.{html,js,ts,jsx,tsx}",
    "./src/**/*.{html,js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Add tailwindcss and autoprefixer property to our postcss.config.js file.

/packages/tailwind/postcss.config.js

export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

Add @monorepo/tailwind package to the frontend application

pnpm -w frontend add -D @monorepo/tailwind

Re-export default Tailwind config from tailwind package

/apps/frontend/tailwind.config.js

export * from "@monorepo/tailwind/tailwind.config.js";

Re-export default PostCSS config from tailwind package

/apps/frontend/postcss.config.js

export * from "@monorepo/tailwind/postcss.config.js";

Add the Tailwind directives to our CSS

/apps/frontend/src/index.css

@tailwind base;
@tailwind components;
@tailwind utilities;

Let's update our button component to use Tailwind class

/apps/frontend/src/App.tsx

const App = () => {
  return <Button className="bg-yellow-500">Hello World</Button>;
};

Without restarting our server, we should see our button background should now be yellow.

Conclusion

And there you have it! We've covered the basics of setting up a monorepo using PNPM workspaces. Additionally, we've explored configuring a project in library mode using Vite and adding Tailwind support. Embracing a monorepo approach changes our perspective on how we organize code, reducing barriers to creating new projects and promoting efficient code sharing.

If you're interested in exploring the finalized code from the blog, here is the GitHub repo.

Thank you for reading! If you got up to this point, please consider following for more upcoming blogs.