DEV Community

Cover image for Monorepo Architecture with pnpm Workspace, Turborepo & Changesets πŸ“¦
Yasin ATEŞ
Yasin ATEŞ

Posted on

Monorepo Architecture with pnpm Workspace, Turborepo & Changesets πŸ“¦

When you're developing a project with multiple packages, managing each one in its own repo can quickly turn into a nightmare. In this article, we'll set up a monorepo architecture from scratch using pnpm workspace, speed up build processes with Turborepo, and build an automated NPM publish pipeline with Changesets.


πŸ—οΈ What Is a Monorepo?

Let's say you're building a design system. You have a core package, a theme package, and a utils package. Now imagine keeping all of these in separate repositories.

When you fix a bug in the core package, what happens? You switch to the theme repo and update the dependency. Then you switch to the utils repo. You open separate PRs for each, wait for separate CI/CD pipelines, and publish separately. Even with just three packages, this process is exhausting β€” with ten, it's a total nightmare.

This is exactly where the monorepo comes in.

A monorepo is an architectural approach that houses multiple projects under a single repository. All your packages live in the same repo, share the same commit history, and can easily reference each other.

So why does this matter? Let's look at a few key advantages:

β˜… Code sharing in one place: Shared utilities, types, and configurations are instantly available to all packages. No need to wait for an NPM publish.

β˜… Atomic changes: You can make a change that affects multiple packages in a single commit. No more cross-repo PR synchronization headaches.

β˜… Consistent tooling: ESLint, Prettier, and TypeScript configurations are managed from a single source. The question "Why does this repo have different rules?" becomes a thing of the past.

β˜… Easy onboarding: When a new developer joins the project, a single git clone and pnpm install brings up the entire ecosystem.


πŸ”§ Setting Up a pnpm Workspace

pnpm workspace setup

pnpm stands out among Node.js package managers for its monorepo support. Unlike npm and yarn, its content-addressable store keeps all dependencies in a global store and links them into your projects via hard links. This saves a massive amount of disk space.

Let's walk through setting up a monorepo architecture with pnpm workspace using a sample project.

Step 1: Install pnpm and Initialize the Project

Install pnpm globally:

npm install -g pnpm
Enter fullscreen mode Exit fullscreen mode

Then initialize a new monorepo project:

mkdir my-design-system
cd my-design-system
pnpm init
git init
Enter fullscreen mode Exit fullscreen mode

β˜… The pnpm init command creates a package.json file at the root level.

β˜… This file will serve as the main entry point for your monorepo. All workspace scripts will be managed from here.

Step 2: Create pnpm-workspace.yaml

Create a pnpm-workspace.yaml file in the project root:

packages:
  - "packages/*"
  - "apps/*"
  - "website"
Enter fullscreen mode Exit fullscreen mode

β˜… The packages/* expression defines each folder under the packages directory as a separate workspace package.

β˜… apps/* includes applications, and website adds the documentation site to the workspace.

β˜… Without this file, pnpm won't recognize your project as a monorepo.

Step 3: Configure the Root package.json

Configure the root package.json for the monorepo:

{
  "name": "my-design-system",
  "private": true,
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "clean": "turbo run clean"
  },
  "devDependencies": {
    "turbo": "^2.3.1",
    "typescript": "^5.7.2",
    "tsup": "^8.3.5",
    "vitest": "^2.1.6",
    "prettier": "^3.4.1"
  },
  "packageManager": "pnpm@9.14.2",
  "engines": {
    "node": ">=18.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

β˜… "private": true is a critical setting. It prevents the root package from being accidentally published to NPM.

β˜… The "packageManager" field ensures that everyone who clones the project uses the same pnpm version. Corepack reads this field and automatically activates the correct package manager.

β˜… Shared devDependencies (like TypeScript and Prettier) are installed at the root, so individual packages don't need to install them separately.

Step 4: Create the Project Structure

Now let's create the folder structure:

mkdir -p packages/core
mkdir -p packages/utils
mkdir -p packages/theme
mkdir -p apps/docs
Enter fullscreen mode Exit fullscreen mode

A typical monorepo structure looks like this:

my-design-system/
β”œβ”€β”€ packages/
β”‚   β”œβ”€β”€ core/               # Core library
β”‚   β”œβ”€β”€ utils/              # Shared utility functions
β”‚   └── theme/              # Theme and style definitions
β”œβ”€β”€ apps/
β”‚   └── docs/               # Documentation app
β”œβ”€β”€ .changeset/             # Changesets configuration
β”œβ”€β”€ .github/                # CI/CD workflows
β”œβ”€β”€ package.json            # Root workspace config
β”œβ”€β”€ pnpm-workspace.yaml     # Workspace definition
β”œβ”€β”€ turbo.json              # Turborepo pipeline
β”œβ”€β”€ tsconfig.base.json      # Shared TypeScript config
└── pnpm-lock.yaml          # Lock file
Enter fullscreen mode Exit fullscreen mode

β˜… Libraries and shared modules live under packages/. Each one can be published as an independent npm package.

β˜… Applications that consume these packages go under apps/.

β˜… Configuration files at the root apply across the entire monorepo.

You also need to initialize each sub-package. For example, for packages/core:

cd packages/core
pnpm init
Enter fullscreen mode Exit fullscreen mode

This creates a package.json for each package. Using namespaced package names is a good practice:

{
  "name": "@myds/core",
  "version": "0.1.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
  }
}
Enter fullscreen mode Exit fullscreen mode

β˜… Scoped names like @myds/core prevent namespace collisions on NPM.

β˜… The main and types fields ensure that other packages resolve the correct files when importing this package.

β˜… tsup is a great tool for quickly bundling TypeScript packages. It produces output in both CJS and ESM formats.


πŸ”— Cross-Package Dependency Management with the workspace Protocol

pnpm workspace* protocol

One of the most powerful features of a monorepo is the ability for packages to reference each other locally. pnpm achieves this through the workspace: protocol.

Let's make this concrete with an example. Say the @myds/theme package depends on @myds/core. You'd define this dependency like so:

{
  "name": "@myds/theme",
  "version": "0.4.0",
  "dependencies": {
    "@myds/core": "workspace:*"
  }
}
Enter fullscreen mode Exit fullscreen mode

β˜… The workspace:* expression tells pnpm: "Don't download this package from the npm registry β€” use the local version from the workspace."
β˜… During development, packages are linked together via symlinks. Any change you make in core is instantly reflected in theme.

But what happens at publish time? Here's the beauty of pnpm: when you run pnpm publish, workspace:* is automatically converted to the actual version number.

So while package.json looks like this during development:

{
  "dependencies": {
    "@myds/core": "workspace:*"
  }
}
Enter fullscreen mode Exit fullscreen mode

It becomes this when published to NPM:

{
  "dependencies": {
    "@myds/core": "0.4.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Variants of the workspace Protocol

pnpm offers several workspace protocol variants:

β˜… workspace:* is the most commonly used variant. It means "use whatever version is currently in the workspace." At publish time, it resolves to an exact version (e.g., "0.4.0").

β˜… workspace:^ resolves to a caret range at publish time (e.g., "^0.4.0"). This allows minor and patch updates.

β˜… workspace:~ resolves to a tilde range at publish time (e.g., "~0.4.0"). This allows only patch updates.

Here's my take on when to use which: if you always publish all packages together, workspace:* is sufficient. But if you want consumers to be able to mix different versions, workspace:^ offers more flexibility.

Adding Dependencies Between Packages

To add one workspace package as a dependency of another, use the --filter flag:

pnpm add @myds/core --filter @myds/theme
Enter fullscreen mode Exit fullscreen mode

β˜… The --filter flag runs the command only in the specified package.

β˜… pnpm automatically adds the dependency as workspace:^ (if it exists in the workspace).

You can also run scripts across all dependents of a specific package:

# Build @myds/core and all packages that depend on it
pnpm --filter "@myds/core..." build
Enter fullscreen mode Exit fullscreen mode

To add a dependency to the root workspace, use the --workspace-root (or -w for short) flag:

pnpm add -D typescript --workspace-root
Enter fullscreen mode Exit fullscreen mode

⚑ Build Orchestration with Turborepo

turborepo

While pnpm workspace alone is sufficient for package management and linking, build processes get more complex as the number of packages grows. Questions like "Which package should be built first?" and "Do we really need to rebuild packages that haven't changed?" start to come up.

This is exactly where Turborepo steps in. Turborepo is a build system developed by Vercel that works directly with pnpm workspace. It reads pnpm's workspace structure, analyzes the dependency graph between packages, and optimizes tasks accordingly. It has two core features:

β˜… Smart task scheduling: It analyzes the dependency graph between packages and automatically determines the build order. Packages that don't depend on each other are run in parallel.

β˜… Hard caching: If a package hasn't changed, it restores the previous build output from cache. While the first build might take 30 seconds, a cached build can complete in 0.2 seconds.

Let's Set Up Turborepo

Add turbo to the root package.json:

pnpm add -D turbo --workspace-root
Enter fullscreen mode Exit fullscreen mode

β˜… The --workspace-root flag (or -w for short) installs the package at the root workspace, making turbo available across the entire monorepo.

turbo.json Configuration

Create a turbo.json file at the project root:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    },
    "test:watch": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "outputs": []
    },
    "clean": {
      "cache": false
    },
    "check-types": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's pause here and understand something important: the ^ in "dependsOn": ["^build"] is crucial. It means "run my dependencies' build task first, then build me." So before @myds/theme is built, its dependency @myds/core gets built first.

β˜… "outputs": ["dist/**"] tells Turborepo which files to cache. On the next build, if the source code hasn't changed, the dist/ folder is restored from cache.

β˜… "cache": false disables caching for certain tasks. Caching doesn't make sense for long-running (persistent) tasks like dev servers.

β˜… "persistent": true keeps the task running in the background. This is necessary for scenarios like dev servers and watch mode.

Now you can build all packages with a single command:

pnpm build
# This runs turbo run build
# Turborepo analyzes the dependency graph
# Builds in order: first core, then theme, then utils...
# Restores unchanged packages from cache
Enter fullscreen mode Exit fullscreen mode

πŸ¦‹ Version Management with Changesets

Changesets

One of the trickiest aspects of a monorepo is version management. You have multiple packages, each potentially with its own independent version number. How do you decide which one to bump and when, or how to generate the changelog?

This is exactly where Changesets comes in. Changesets is a versioning tool that lets you record each change as a small markdown file.

Step 1: Install Changesets

pnpm add -D @changesets/cli --workspace-root
pnpm changeset init
Enter fullscreen mode Exit fullscreen mode

β˜… The changeset init command creates a .changeset/ directory in your project.
β˜… Inside this directory, you'll find a config.json and a README.md file.

Step 2: Configure Changesets

Configure the .changeset/config.json file for your project:

{
  "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
  "changelog": [
    "@changesets/changelog-github",
    { "repo": "your-org/your-monorepo" }
  ],
  "commit": false,
  "fixed": [],
  "linked": [["@myds/*"]],
  "access": "public",
  "baseBranch": "master",
  "updateInternalDependencies": "patch",
  "ignore": []
}
Enter fullscreen mode Exit fullscreen mode

Let's break down each of these settings:

β˜… In the changelog section, we're using @changesets/changelog-github. This automatically adds PR links and contributor information to the changelog. Let's install this package as well:

pnpm add -D @changesets/changelog-github --workspace-root
Enter fullscreen mode Exit fullscreen mode

β˜… The linked field is very important. [["@myds/*"]] tells Changesets that the versions of all packages under the @myds scope are linked together. If one gets a major bump, they all do.

β˜… "access": "public" ensures that scoped packages (like @myds/core) are published as public on NPM. Without this setting, scoped packages default to private and will throw an error during publish.

β˜… "updateInternalDependencies": "patch" automatically applies a patch bump to the internal dependency versions of packages that depend on an updated package.

β˜… "baseBranch": "master" determines which branch Changesets uses as its baseline. Depending on your project, this could be main or master.

Step 3: Create a Changeset

When you make a change, create a changeset file before opening a PR:

pnpm changeset
Enter fullscreen mode Exit fullscreen mode

This launches an interactive wizard:

πŸ¦‹  Which packages would you like to include?
  β—― @myds/core
  β—― @myds/theme
  β—― @myds/utils

πŸ¦‹  Which packages should have a major bump?
πŸ¦‹  Which packages should have a minor bump?

πŸ¦‹  Summary: Added dark mode support to the core package
Enter fullscreen mode Exit fullscreen mode

This wizard creates a randomly named markdown file in the .changeset/ directory:

---
"@myds/core": minor
"@myds/theme": patch
---

Added dark mode support to the core package. Updated related color variables in the theme package.
Enter fullscreen mode Exit fullscreen mode

β˜… The frontmatter section specifies which package gets bumped at which level.
β˜… The text below becomes the description added to the changelog.
β˜… This file is included in the commit and submitted for review along with the PR.

Step 4: Apply the Versions

Once all changesets have been collected, apply the versions:

pnpm changeset version
Enter fullscreen mode Exit fullscreen mode

β˜… This command reads all changeset files.
β˜… Updates the version numbers in the relevant packages' package.json files.
β˜… Automatically creates/updates each package's CHANGELOG.md.
β˜… Deletes the consumed changeset files.

Step 5: Publish

After versions have been applied, publish:

pnpm changeset publish
Enter fullscreen mode Exit fullscreen mode

This publishes all changed packages to the NPM registry. But are you going to do this manually every time? Of course not! In the next section, we'll automate this entire process 😁


πŸš€ Automated NPM Publishing with GitHub Actions

auto npm pusblish with github actions

Enough theory β€” time to get our hands dirty! πŸ› οΈ The real power of Changesets shines when combined with GitHub Actions. Let's build a CI/CD pipeline step by step.

Step 1: Create an NPM Token

First, you need to create an Automation Token on NPM:

β˜… Go to your profile settings on npmjs.com.

β˜… In the Access Tokens section, select Generate New Token.

β˜… Set the token type to Automation. This type allows publishing without requiring 2FA.

β˜… Copy the generated token (it's only shown once!).

Step 2: Add Tokens to GitHub Secrets

In your GitHub repo, go to Settings > Secrets and variables > Actions and add the following secret:

β˜… NPM_TOKEN: The NPM automation token you just created.

GITHUB_TOKEN is automatically provided by GitHub for every workflow run, so you don't need to set it up manually.

Step 3: Configure Repository Permissions

In your GitHub repo, go to Settings > Actions > General:

β˜… Under Workflow permissions, enable the "Read and write permissions" option.

β˜… Check the "Allow GitHub Actions to create and approve pull requests" checkbox.

Without these settings, the Changesets bot won't be able to create PRs.

Step 4: Create the Release Workflow File

Now create the .github/workflows/release.yml file:

name: Release

on:
  push:
    branches:
      - master

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

permissions:
  contents: write
  pull-requests: write
  packages: write
  id-token: write

jobs:
  release:
    name: Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repo
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
          registry-url: "https://registry.npmjs.org"

      - name: Install Dependencies
        run: pnpm install --frozen-lockfile

      - name: Build All Packages
        run: pnpm build

      - name: Create Release Pull Request or Publish
        id: changesets
        uses: changesets/action@v1
        with:
          commit: "chore: update versions"
          title: "chore: update versions"
          publish: pnpm run ci:publish
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

There's a lot going on in this workflow. Let's break it down piece by piece:

β˜… The concurrency setting prevents multiple release workflows from running simultaneously. If a new push comes in while a publish is in progress, the old workflow isn't cancelled β€” instead, the new one waits in the queue.

β˜… The permissions block gives the workflow the authority to write to the repo, create PRs, and publish packages.

β˜… fetch-depth: 0 pulls the full git history. Changesets needs the commit history to calculate versions.

β˜… pnpm/action-setup@v4 installs pnpm in the CI environment. It reads the packageManager field from package.json to install the correct version.

β˜… --frozen-lockfile is a critical flag for CI. It installs the exact same dependencies as specified in the lock file. If pnpm-lock.yaml is out of date, it throws an error.

β˜… changesets/action@v1 is the action that does all the heavy lifting. It handles two different scenarios:

Scenario A: If there are pending changeset files in the .changeset/ directory, it creates or updates a "Version Packages" PR. This PR contains the version bumps and changelog updates.

Scenario B: If there are no pending changesets (i.e., after the Version Packages PR has been merged), it runs the publish command to publish the packages to NPM.

Step 5: Define the ci:publish Script

Add the publish script to the root package.json:

{
  "scripts": {
    "ci:publish": "pnpm publish -r --access public"
  }
}
Enter fullscreen mode Exit fullscreen mode

β˜… The -r flag (recursive) publishes all workspace packages.
β˜… --access public ensures that scoped packages are published as public.

Changeset Bot

Optionally, you can install the Changeset Bot from github.com/apps/changeset-bot on your repo. This bot checks whether PRs include a changeset file and leaves a warning comment if they don't.


🎯 Demo: The Tuvix.js Monorepo Project

If you'd like to see the entire architecture described in this article in action, check out the Tuvix.js project I've been developing. Tuvix.js is a lightweight micro frontend framework that uses exactly the stack covered in this article:

β˜… pnpm workspace managing 14+ packages in a single repo

β˜… Turborepo for build orchestration and caching

β˜… Changesets for version management and automated NPM publishing

β˜… GitHub Actions for the CI/CD pipeline

You can explore the pnpm-workspace.yaml, turbo.json, .changeset/config.json, and GitHub Actions workflow files directly in the repository.

Check out the repo here πŸ‘‡

github.com/yasinatesim/tuvix.js


πŸ“¬ Feedback

While writing this article, I used my own notes for identifying sources and research, the Claude Opus 4.6 model for proofreading and additional research, and the Gemini 3 Pro Preview 2k (Nano Banana Pro) model for generating images.

I welcome any advice, suggestions, or feedback on this article. If you'd like to get in touch, you can reach me through my social media links on my website or via LinkedIn.

Best, Yasin πŸ€—


πŸ“š Resources Used While Writing This Article

  1. pnpm Workspaces β€” Official documentation for pnpm's workspace feature
  2. Using Changesets with pnpm β€” Guide on Changesets integration with pnpm
  3. Turborepo - Structuring a Repository β€” Turborepo's monorepo structuring documentation
  4. Changesets GitHub Action β€” Official Changesets GitHub Action repository and usage guide
  5. Tuvix.js GitHub Repository β€” My micro frontend framework, this is monorepo project referenced in this article

Top comments (0)