DEV Community

usapop
usapop

Posted on

Managing Multiple Related npm Packages with a Monorepo

When you want to publish multiple related packages to npm — like "core logic + React/Vue wrappers" — managing them becomes a question.

I initially thought "separate repositories would be cleaner" and managed them individually. But as packages grew, things got painful. Every time I fixed core, I had to open PRs in three repositories. npm link wouldn't work as expected and I'd waste hours debugging. Before I knew it, versions were all over the place...

For recent projects, I've been creating them as monorepos, and these headaches have mostly gone away. You don't need special tools — npm workspaces alone gets you pretty far.

This article summarizes that experience. There might be better approaches out there, so consider this just a reference.

Problems with Separate Repositories

Managing related packages in separate repositories led to these issues:

Situation Problem
Development Every core fix required dependency update PRs in react/vue repos
Development npm link wouldn't work properly (symlink resolution, duplicate node_modules)
Development ESLint/Prettier/TypeScript configs copied to each repo, had to sync changes
Publishing Had to follow: publish core → update react/vue dependency versions → publish
Publishing When rushing, forgot updates and versions diverged (core at 1.2.0, react at 1.0.3)
Publishing Without scopes, package names were scattered and relationships unclear
GitHub With personal accounts, just 3 repos side by side with no visible connection
GitHub Creating an Organization for a small library feels overkill
GitHub Issues and discussions scattered across repos

What Changed with Monorepo

Issue Separate Repos Monorepo
Core fixes 3 PRs in 3 repos 1 PR in 1 repo
Local dev Manual npm link management Workspaces auto-link
CI config Setup per repo One for all packages
Version management Individual, prone to drift Centralized
Config files Copy-paste per repo Shared at root

Basically, "packages that change together should live together." Conversely, packages that develop and release independently don't need to be forced into a monorepo.

When to Use (or Not)

Here's my take:

Monorepo works well for:

  • Core library + framework bindings (React/Vue/Svelte)
  • Shared utilities + various adapters
  • Design system component sets
  • Packages that frequently change together

Separate repos are fine for:

  • Completely independent packages (changes don't correlate)
  • Very different release cycles
  • Different teams managing them

From here, I'll explain the concrete steps to publish npm packages from a monorepo.

Structure assumed in this article:

  • @your-scope/core - Framework-agnostic core logic
  • @your-scope/react - React wrapper (depends on core)
  • @your-scope/vue - Vue wrapper (depends on core)

Dependency diagram:

┌─────────────────────────────────┐
│     User's Project              │
│  ┌───────────────────────────┐  │
│  │      Application          │  │
│  └───────────────────────────┘  │
│        │             │          │
│        ▼             ▼          │
│   ┌────────┐    ┌────────┐      │
│   │ react  │    │  vue   │      │
│   └────────┘    └────────┘      │
└─────────────────────────────────┘
        │             │
        ▼             ▼
┌──────────────┐ ┌──────────────┐
│@your-scope/  │ │@your-scope/  │
│    react     │ │     vue      │
└──────────────┘ └──────────────┘
        │             │
        └──────┬──────┘
               ▼
        ┌──────────────┐
        │@your-scope/  │
        │     core     │
        └──────────────┘
Enter fullscreen mode Exit fullscreen mode

Since react/vue packages depend on core, core must be published first. When bumping versions, if core goes up, react/vue dependency versions need to follow.

I'll use npm as the package manager. The basic concepts apply to pnpm and yarn too, though some details differ.

Project Structure

my-library/
├── package.json          # Root (workspaces definition)
├── tsconfig.json         # Shared TypeScript config
├── packages/
│   ├── core/
│   │   ├── package.json  # @your-scope/core
│   │   ├── tsconfig.json # Extends root
│   │   ├── vite.config.ts
│   │   └── src/
│   │       └── index.ts
│   ├── react/
│   │   ├── package.json  # @your-scope/react
│   │   ├── tsconfig.json
│   │   ├── vite.config.ts
│   │   └── src/
│   │       └── index.ts
│   └── vue/
│       ├── package.json  # @your-scope/vue
│       ├── tsconfig.json
│       ├── vite.config.ts
│       └── src/
│           └── index.ts
└── examples/             # For testing (not published)
    ├── vanilla/
    ├── react/
    └── vue/
Enter fullscreen mode Exit fullscreen mode

The examples/ directory is for testing during development. Including it in workspaces lets it reference local packages, which is convenient. Setting "private": true prevents accidental publishing.

Implementation Steps

1. Root package.json

{
  "name": "my-library",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "workspaces": [
    "packages/*",
    "examples/*"
  ],
  "scripts": {
    "dev": "npm run dev --workspaces --if-present",
    "build": "npm run build --workspaces --if-present",
    "test": "vitest",
    "lint": "eslint packages",
    "format": "prettier --write ."
  },
  "devDependencies": {
    "eslint": "^9.0.0",
    "prettier": "^3.5.0",
    "typescript": "^5.8.0",
    "vitest": "^3.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

A few points:

  • "private": true - The root itself won't be published to npm. Forgetting this could cause accidents with npm publish
  • workspaces - Specifies directories to manage. Wildcards like packages/* work
  • --workspaces --if-present - Runs scripts across all workspaces. --if-present skips packages without that script

Putting devDependencies at root avoids duplicate installations across packages. Things used everywhere like eslint, prettier, typescript are easier to manage at root.

On the other hand, things used only in specific packages (e.g., @vitejs/plugin-react only for the react package) are clearer in that package's devDependencies.

# Add to root (shared across all)
npm install -D -W eslint prettier typescript

# Add to specific package
npm install -D -w packages/react @vitejs/plugin-react
Enter fullscreen mode Exit fullscreen mode

-W (--workspace-root) adds to root package.json, -w (--workspace) adds to the specified workspace.

2. Shared TypeScript Config

// tsconfig.json (root)
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "isolatedModules": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Each package extends this root config, overriding as needed.

// packages/core/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode
// packages/react/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "jsx": "react-jsx"
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

3. Package-level package.json

Core package:

{
  "name": "@your-scope/core",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "vite build",
    "dev": "vite build --watch"
  },
  "devDependencies": {
    "vite": "^6.0.0",
    "vite-plugin-dts": "^4.5.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Some notes:

  • exports - Available since Node.js 12.7.0, explicitly defines entry points. Takes precedence over main/module. Convention is to put types first in conditional exports since Node.js evaluates conditions top-down, ensuring TypeScript resolves types correctly
  • files - Specifies what to include in the npm package. Just dist means src and test files won't be included. You can also use .npmignore, but whitelist via files is less accident-prone
  • vite-plugin-dts - Plugin to generate TypeScript declaration files (.d.ts) with Vite

Framework binding (React):

{
  "name": "@your-scope/react",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "vite build",
    "dev": "vite build --watch"
  },
  "dependencies": {
    "@your-scope/core": "^1.0.0"
  },
  "peerDependencies": {
    "react": ">=17.0.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.4.0",
    "react": "^19.0.0",
    "vite": "^6.0.0",
    "vite-plugin-dts": "^4.5.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

A note on peerDependencies:

Frameworks like React should already be installed in the user's project, so we specify them as peerDependencies. This way, the library doesn't bundle React and uses the user's React instance instead.

If React gets bundled multiple times, hooks break and other issues occur, so this matters.

Meanwhile, we need React during development, so it's also in devDependencies.

4. Build Config (Vite)

// packages/core/vite.config.ts
import { defineConfig } from 'vite'
import dts from 'vite-plugin-dts'

export default defineConfig({
  build: {
    lib: {
      entry: 'src/index.ts',
      formats: ['es', 'cjs'],
      fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`,
    },
    sourcemap: true,
    // Not minifying in library mode makes debugging easier for users
    minify: false,
  },
  plugins: [
    dts({
      rollupTypes: true, // Bundle type definitions into one file
    }),
  ],
})
Enter fullscreen mode Exit fullscreen mode
// packages/react/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'

export default defineConfig({
  build: {
    lib: {
      entry: 'src/index.ts',
      formats: ['es', 'cjs'],
      fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`,
    },
    sourcemap: true,
    minify: false,
    rollupOptions: {
      // Packages to exclude from bundle
      external: ['react', 'react/jsx-runtime', '@your-scope/core'],
    },
  },
  plugins: [
    react(),
    dts({
      rollupTypes: true,
    }),
  ],
})
Enter fullscreen mode Exit fullscreen mode

About rollupOptions.external:

When building a library, you need to decide whether to include dependencies in the bundle. Packages in external won't be bundled — they'll use what's installed in the user's environment.

  • react, react/jsx-runtime - peerDependencies, don't include
  • @your-scope/core - It's a dependency, but users install it separately, so don't include

Conversely, packages not in external get bundled. Small utility libraries that you don't want users to install separately could be bundled.

5. Publishing to npm

First-time publishing requires explicitly marking the scope as public.

# Build
npm run build

# Publish (in dependency order)
cd packages/core && npm publish --access public
cd ../react && npm publish --access public
cd ../vue && npm publish --access public
Enter fullscreen mode Exit fullscreen mode

--access public isn't needed after the first time.

Automating with Changesets

Manually running npm publish repeatedly gets painful as packages grow. You might mess up the order or forget to update dependency versions.

Changesets automates this. Manual works fine for small projects, but as packages grow or release frequency increases, it might be worth considering.

What Changesets Does

  • Dependency-aware version updates - When core goes up, it auto-updates the dependency version in react/vue
  • Auto-generated CHANGELOG.md - Records changes and writes them to files on publish
  • "Scheduling" changes - During development, just record "made this change," then process everything together at release time

Setup

Initialize at root:

npm install -D @changesets/cli
npx changeset init
Enter fullscreen mode Exit fullscreen mode

This creates a .changeset/ directory and config file.

Workflow

1. Record changes

After adding features or fixing bugs, before committing:

npx changeset
Enter fullscreen mode Exit fullscreen mode

Interactive prompts ask "which packages changed," "major/minor/patch," and "description." This creates a small markdown file in .changeset/ which you commit as-is.

2. Apply versions

When ready to release:

npx changeset version
Enter fullscreen mode Exit fullscreen mode

Records in .changeset/ are consumed, auto-updating each package's package.json version and CHANGELOG.md. It considers dependencies, so if core's version goes up, react/vue dependency versions follow.

3. Publish

Finally, publish everything at once:

npx changeset publish
Enter fullscreen mode Exit fullscreen mode

Packages with unpublished versions get uploaded to npm in dependency order.

GitHub Actions Integration

Combined with GitHub Actions, you can automate further. When a changeset is merged to main, it auto-creates a version update PR. When that PR is merged, it auto-publishes to npm.

There's an official GitHub Action, check it out if interested.

I'm still doing it manually though...

Gotchas

1. Don't Use workspace:* Protocol

pnpm and yarn berry (v2+) let you reference local packages with workspace:*:

{
  "dependencies": {
    "@your-scope/core": "workspace:*"
  }
}
Enter fullscreen mode Exit fullscreen mode

This is convenient during development, but there's a problem. pnpm auto-replaces this with version numbers on npm publish, but npm and yarn v1 publish workspace:* as-is, causing install errors.

I wrote about this in detail in another article (Japanese):

With npm workspaces, just write the version number normally. If a matching local package exists, it automatically symlinks.

{
  "dependencies": {
    "@your-scope/core": "^1.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Mind the Publish Order

Packages with dependencies must publish the dependency first.

1. @your-scope/core   ← Publish first
2. @your-scope/react  ← Depends on core
3. @your-scope/vue    ← Depends on core
Enter fullscreen mode Exit fullscreen mode

If you try to publish react before core, it'll error saying @your-scope/core@^1.0.0 not found.

If this is tedious, tools like Changesets auto-publish in dependency order. Though for small projects, manual is probably fine.

3. Don't Forget Dependency Version Updates

When bumping versions, dependent packages' reference versions need updating too.

// packages/core/package.json
{ "version": "1.1.0" }

// packages/react/package.json
{
  "version": "1.1.0",
  "dependencies": {
    "@your-scope/core": "^1.1.0"  //  Update this too
  }
}
Enter fullscreen mode Exit fullscreen mode

Forgetting this leaves react depending on old core. Manual updates are error-prone, so automating with shell scripts or npm scripts might help.

4. Unified vs Individual Versioning

Monorepo version management has two main approaches:

Unified versions (fixed versioning)

Used by Vue, React, Jest, and many famous projects. Even if only core changes, react/vue get the same version bump.

@your-scope/core: 1.2.0
@your-scope/react: 1.2.0
@your-scope/vue: 1.2.0
Enter fullscreen mode Exit fullscreen mode

Simpler to manage. Users don't have to wonder "which version combination is correct." For personal projects, this probably has fewer accidents.

Individual versioning

Only bump packages that changed. If only core changed, only core goes up.

@your-scope/core: 1.2.0
@your-scope/react: 1.1.0  // No changes, stays
@your-scope/vue: 1.1.0
Enter fullscreen mode Exit fullscreen mode

Per-package change history is clearer, but maintaining dependency consistency is a bit more work. Changesets handles this well.

I use unified versioning. For small libraries, simplicity of unified management seems to outweigh benefits of individual management.

Conclusion

Monorepos aren't a silver bullet, but they're a convenient option when you want to manage related packages together.

npm workspaces work without special tools, so maybe start small and add pnpm or Changesets as needed.

References

Top comments (0)