Skip to Content
chalvien 1.0 is released
DocumentationTutorialsMonorepoMonorepo Part 1

Building a Full-Stack Monorepo

Tutorial Introduction

Managing multiple applications and packages within a single project can quickly become unmanageable. Whether you are an experienced developer or an intern, you have likely faced the challenge of synchronizing a frontend application with a backend API or attempting to share UI components and utility functions across different parts of a codebase.

Monorepos offer a streamlined solution to these challenges by organizing multiple applications and shared packages into a single repository, which significantly improves collaboration and development efficiency. In this guide, we will walk you through the process of setting up a professional monorepo using a modern stack:

  • Frameworks & Tools: Turbopack, Biome, Next.js 15, Express.js, Tailwind CSS, and ShadCN.
  • Package Management: We will use pnpm to optimize dependency management and performance.

By the end of this tutorial, you will have a fully functioning monorepo featuring two applications—Next.js and Express.js—along with three shared packages for UI components, TypeScript types, and utility functions.

Prerequisites

Before starting this tutorial, ensure you have the following installed:

  • Visual Studio Code (or a similar code editor that supports Biome)
  • Biome extension for VSC
  • NodeJS (recommended to install via NVM for easy version management)

What is a Monorepo?

Before we start building, let’s clarify what a monorepo is and why it’s useful.

Definition

A monorepo is a single repository that holds the code for multiple projects or packages. Instead of having separate repositories for each app or shared library, everything lives in one place.

Benefits of Using a Monorepo

  • Code Sharing: Easily share code between different apps (e.g., UI components or utility functions).
  • Consistency: Maintain consistent dependencies and configurations across all projects.
  • Simplified Collaboration: Developers working on different parts of the project can collaborate more easily since everything is in one place.
  • Atomic Changes: Make changes across multiple apps or packages in one commit.
  • Centralized CI/CD: Manage continuous integration and deployment pipelines from one place.

In this guide, we’ll create a monorepo (turborepo) that contains:

  • A Next.js app (frontend).
  • An Express.js app (backend).
  • Packages for UI components (using Tailwind CSS + ShadCN), shared types, and utility functions.

Why Use pnpm and Turbopack?

To make our monorepo efficient and scalable, we’ll use two key tools: pnpm for package management and Turbopack/Turborepo for fast builds.

pnpm

pnpm is an alternative to npm and Yarn that offers several advantages:

  • Faster Installs: pnpm installs dependencies faster by using hard links instead of copying files.
  • Disk Space Efficiency: It saves disk space by avoiding duplicate dependencies.
  • Workspaces Support: pnpm supports workspaces natively, making it ideal for monorepos where you have multiple projects sharing dependencies.

Turbopack

Turbopack is the new bundler introduced by Vercel for Next.js. It’s designed to be much faster than Webpack, especially during development:

  • Faster Hot Module Replacement (HMR): Turbopack speeds up development by reloading only the necessary modules when you make changes.
  • Optimized Production Builds: Turbopack optimizes your production builds to be smaller and faster.
  • Seamless Integration with Next.js 15: Turbopack works out of the box with Next.js new app directory structure.

With these tools in hand, let’s move on to setting up our project structure.

Project Structure Overview

Here’s what our final project structure will look like:

monorepo/ ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── apps/ │ ├── web/ # Next.js app │ └── server/ # Express.js app ├── packages/ │ ├── ui/ # Shared UI components (using Tailwind CSS + ShadCN) │ ├── types/ # Shared TypeScript types │ ├── tsconfig/ # Typescript configuration │ └── utils/ # Shared utility functions ├── .gitignore # Untrack files from git ├── biome.json # Biome configuration ├── package.json # Project configuration ├── turbo.json # Turbopack configuration └── pnpm-workspace.yaml # pnpm workspace configuration

We’ll organize our project into two main directories:

  • apps/: This will contain our two main applications—web (Next.js) and server (Express.js).
  • packages/: This will contain shared code that both apps can use—ui for shared UI components, types for TypeScript types, utils for shared utility functions and tsconfig for typescript configuration files.

Now that we have an overview of the structure, let’s start setting up the monorepo.

Setting Up the Monorepo

Step 1: Initialize the Monorepo with pnpm Workspaces

First, we need to install pnpm globally if you don’t already have it:

npm install -g pnpm

Next, create your main project directory:

mkdir monorepo && cd monorepo

Initialize a new workspace:

pnpm init

This command creates a package.json file at the root of your project. Now we need to tell pnpm which directories should be part of the workspace by creating a pnpm-workspace.yaml file at the root:

packages: - 'apps/*' - 'packages/*'

This configuration tells pnpm that any folder inside apps/ or packages/ should be treated as part of the workspace.

Step 2: Configure Turbopack

Next, we’ll configure Turbopack by creating a turbo.json file at the root of your project:

{ "$schema": "https://turbo.build/schema.json", "globalDependencies": [ "**/.env.*local" ], "tasks": { "topo": { "dependsOn": [ "^topo" ] }, "build": { "dependsOn": [ "^build" ], "outputs": [ "dist/**", ".next/**", "!.next/cache/**" ] }, "lint": { "dependsOn": [ "^topo" ] }, "format": { "dependsOn": [ "^topo" ] }, "lint:fix": { "dependsOn": [ "^topo" ] }, "format:fix": { "dependsOn": [ "^topo" ] }, "check-types": {}, "dev": { "cache": false, "persistent": true }, "add-shadcn-component": { "dependsOn": [ "^topo" ] }, "clean": { "cache": false } } }

This configuration defines how Turbopack should handle builds across your workspace.

Notice the add-shadcn-component command, this is a custom command that will be used in our UI package to easily add new components from ShadCN directly from root.

Step 3: Global configs

Following, we’ll update our root package.json to add scripts and dependencies.

{ "name": "monorepo", "private": true, "scripts": { "changeset": "changeset", "publish:packages": "changeset publish", "version:packages": "turbo build && changeset version", "add-shadcn-component": "turbo run add-shadcn-component -- --", "build": "turbo build", "dev": "turbo dev", "format": "turbo format --continue --", "format:fix": "turbo format --continue -- --write", "lint": "turbo lint --continue --", "lint:fix": "turbo lint --continue -- --apply", "clean": "turbo clean" }, "dependencies": { "@changesets/changelog-github": "^0.5.0", "@changesets/cli": "^2.27.1", "turbo": "^2.1.3" }, "devDependencies": { "@biomejs/biome": "^1.7.2", "typescript": "^5", "postcss": "^8.4.27" }, "packageManager": "pnpm@9.12.1" }

For Biome config, we will create a file named biome.json:

{ "$schema": "https://biomejs.dev/schemas/1.6.4/schema.json", "files": { "ignoreUnknown": true, "ignore": [ "node_modules/*", "*.config.*", "*.json", "tsconfig.json", ".turbo", "**/dist", "**/out", ".next" ] }, "organizeImports": { "enabled": true }, "linter": { "enabled": true, "rules": { "recommended": true, "complexity": { "noForEach": "off", "noUselessFragments": "off" }, "correctness": { "useExhaustiveDependencies": "off", "noUnusedImports": "warn", "noUnusedVariables": "warn" }, "style": { "noParameterAssign": "off" } } }, "formatter": { "enabled": true, "formatWithErrors": false, "indentStyle": "space", "lineEnding": "lf", "lineWidth": 120 } }

A very important file is the .gitignore, this is the file where we will tell Git which files we don’t want to be tracked.

# dependencies /node_modules /.pnp .pnp.js node_modules packages/*/node_modules apps/*/node_modules .next # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug .pnpm-debug.log* # other lockfiles that's not pnpm-lock.yaml package-lock.json yarn.lock # local env files .env .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts # intellij .idea dist/** /dist packages/*/dist .turbo /test-results/ /playwright-report/ /playwright/.cache/

This configuration defines our project defaults. Now that we’ve set up our workspace configuration files, let’s move on to creating our apps.

Step 4: .vscode folder

The .vscode folder in a project directory stores configuration settings specifically for Visual Studio Code. These settings allow you to personalize and optimize VS Code for your project or workspace needs. Here are the two main types of settings:

User Settings: Apply globally across all VS Code instances on your system. They are perfect for settings you want to keep consistent, like font size or theme. Workspace Settings: Apply only to the current project. This is useful for project-specific configurations, like excluding certain folders (e.g., node_modules) from your file explorer. VS Code uses JSON files to store these settings, enabling easy customization and sharing through version control. For easy management, you can modify settings directly in the JSON file or use the Settings editor, which provides a convenient graphical interface.

For our project we will create two files that store these configs. First, create a folder at the root with the name of .vscode. Then, create extensions.json:

{ "recommendations": [ "yoavbls.pretty-ts-errors", "bradlc.vscode-tailwindcss", "biomejs.biome" ] }

The last config we need is the global settings, so create a file named settings.json:

{ "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit", "source.fixAll.biome": "explicit", }, "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, "tailwindCSS.experimental.classRegex": [ ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] ], "typescript.enablePromptUseWorkspaceTsdk": true, "typescript.tsdk": "node_modules/typescript/lib", "typescript.preferences.autoImportFileExcludePatterns": [ "next/router.d.ts", "next/dist/client/router.d.ts" ], "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, "[json]": { "editor.defaultFormatter": "vscode.json-language-features" } }

Creating the first package (tsconfig)

To create the typescript config that will be used in all our monorepo and the individual configs for our web and server, we will create our tsconfig package.

mkdir packages && cd packages && mkdir tsconfig && cd tsconfig

We will have 6 config files for Typescript:

  • base: base.json
  • web: next.json
  • server: express.json
  • ui: ui.json
  • utils: utils.json
  • types: types.json

First we will create our package.json:

{ "name": "@monorepo/tsconfig", "version": "0.0.0", "private": true, "license": "MIT", "publishConfig": { "access": "public" } }

Then we will create our base.json config file (you can find every tsconfig setting here insert link):

{ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "alwaysStrict": false, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "target": "ESNext", "lib": [ "DOM", "DOM.Iterable", "ESNext" ], "noEmit": true, "declaration": true, "declarationMap": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "downlevelIteration": true, "allowJs": true, "isolatedModules": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "skipDefaultLibCheck": true, "incremental": true, "tsBuildInfoFile": ".tsbuildinfo" }, "include": [ "**/*.ts", "**/*.tsx" ], "exclude": [ "node_modules", "src/tests" ] }

Now we will create the next.json config:

{ "$schema": "https://json.schemastore.org/tsconfig", "extends": "./base.json", "compilerOptions": { "paths": { "@/*": [ "./*" ] }, "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, "strict": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true } }

Then we need to create the express.json config file:

{ "$schema": "https://json.schemastore.org/tsconfig", "display": "ExpressJS Server", "extends": "./base.json", "ts-node": { "compilerOptions": { "module": "commonjs", "moduleResolution": "Node10" } }, "compilerOptions": { "outDir": "./build", "emitDecoratorMetadata": true, "experimentalDecorators": true, "module": "ESNext" } }

Create a types.json config file for our shared types package:

{ "$schema": "https://json.schemastore.org/tsconfig", "display": "Shared Types", "extends": "./base.json", "compilerOptions": { "outDir": "./dist", "declaration": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, }, }

Also, we need to add the last config file ui.json, this file will be used in our shared UI package.

{ "$schema": "https://json.schemastore.org/tsconfig", "display": "Shared UI", "extends": "./base.json", "compilerOptions": { "paths": { "@/*": [ "./*" ] }, "allowJs": true, "skipLibCheck": true, "strict": false, "forceConsistentCasingInFileNames": true, "noEmit": true, "incremental": true, "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve" } }

Finally, create the utils.json config:

{ "$schema": "https://json.schemastore.org/tsconfig", "display": "Shared UI", "extends": "./base.json", "compilerOptions": { "paths": { "@/*": [ "./*" ] }, "allowJs": true, "skipLibCheck": true, "strict": false, "forceConsistentCasingInFileNames": true, "noEmit": true, "incremental": true, "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve" } }

This is how your tsconfig package folder will look like:

Insert img here

Congrats, we’ve just finished configuring our typescript. Now let’s heads up to the exciting part: create our apps!