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 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
Then initialize a new monorepo project:
mkdir my-design-system
cd my-design-system
pnpm init
git init
β
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"
β
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"
}
}
β
"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
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
β
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
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"
}
}
β
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
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:*"
}
}
β
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:*"
}
}
It becomes this when published to NPM:
{
"dependencies": {
"@myds/core": "0.4.0"
}
}
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
β
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
To add a dependency to the root workspace, use the --workspace-root (or -w for short) flag:
pnpm add -D typescript --workspace-root
β‘ Build Orchestration with 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
β
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": []
}
}
}
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
π¦ Version Management with 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
β
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": []
}
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
β
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
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
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.
β
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
β
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
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
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 }}
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"
}
}
β
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
- pnpm Workspaces β Official documentation for pnpm's workspace feature
- Using Changesets with pnpm β Guide on Changesets integration with pnpm
- Turborepo - Structuring a Repository β Turborepo's monorepo structuring documentation
- Changesets GitHub Action β Official Changesets GitHub Action repository and usage guide
- Tuvix.js GitHub Repository β My micro frontend framework, this is monorepo project referenced in this article





Top comments (0)