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 │
└──────────────┘
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/
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"
}
}
A few points:
-
"private": true- The root itself won't be published to npm. Forgetting this could cause accidents withnpm publish -
workspaces- Specifies directories to manage. Wildcards likepackages/*work -
--workspaces --if-present- Runs scripts across all workspaces.--if-presentskips 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
-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
}
}
Each package extends this root config, overriding as needed.
// packages/core/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
// packages/react/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src"]
}
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"
}
}
Some notes:
-
exports- Available since Node.js 12.7.0, explicitly defines entry points. Takes precedence overmain/module. Convention is to puttypesfirst in conditional exports since Node.js evaluates conditions top-down, ensuring TypeScript resolves types correctly -
files- Specifies what to include in the npm package. Justdistmeans src and test files won't be included. You can also use.npmignore, but whitelist viafilesis 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"
}
}
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
}),
],
})
// 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,
}),
],
})
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
--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
This creates a .changeset/ directory and config file.
Workflow
1. Record changes
After adding features or fixing bugs, before committing:
npx changeset
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
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
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:*"
}
}
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):
The Pitfall of npm publish with pnpm's workspace:* Protocol
usapop ・ Jan 18
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"
}
}
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
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
}
}
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
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
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.
Top comments (0)