| | |

Vite 27 ⚡ Monorepo Setup — Linked dependencies & workspace resolution

⏱️ Estimated read: 13 min | |

Vite does not have a native concept of a monorepo. It resolves modules the same way it resolves them in a single-package project, and the package manager—npm, yarn, or pnpm—is what creates the links between the workspace packages. The friction appears when Vite encounters a linked package that is not published to a registry and is not pre-built. Vite treats the linked package as source code, not as a dependency, and it tries to resolve the package’s internal imports and transform its files. For a package that exports ESM and is already built, this works. For a package that exports CommonJS, or a package whose exports map is complex, Vite’s default resolution fails, and the error is a module-not-found or a missing export that does not mention the linked package as the cause .

The first configuration decision is whether the linked package is ESM or CommonJS. An ESM package that is properly built and exports its files through the exports field can be left alone, or explicitly excluded from pre-bundling with optimizeDeps.exclude so the browser handles it natively . A CommonJS package needs to be pre-bundled by Vite, and the optimizeDeps.include array is the mechanism that tells Vite to process it. The build.commonjsOptions.include array is the build-time equivalent, and the two must be configured together for the linked CJS package to work in both dev and build .

Key point: Vite treats linked workspace packages as source, not as dependencies. A linked CommonJS package must be added to optimizeDeps.include and build.commonjsOptions.include. A linked ESM package can be added to optimizeDeps.exclude. The resolve.preserveSymlinks option is a workaround for yarn’s symlink behavior, but it breaks hoisted dependencies in some setups.


Why linked packages need explicit handling

The source-vs-dependency problem. Vite’s dependency pre-bundling is designed for packages in node_modules that are not linked. A linked package resolves to a path outside node_modules, and Vite’s scanner treats it as source code that should be transformed, not as a dependency that should be pre-bundled. For an ESM package, this is often correct: the source is transformed and served, and HMR works. For a CommonJS package, it is wrong: the CJS require calls are not transformed, and the browser fails with a module error .

The symlink problem. Yarn and pnpm create symlinks for workspace packages. Vite’s resolver follows the symlinks by default, which resolves the package to its real path. The resolve.preserveSymlinks: true option tells Vite to keep the symlink path instead. This was a common fix for yarn workspaces in Vite 2 and 3, but it has a side effect: Vite can no longer load hoisted packages from node_modules in parent folders, and in some projects it breaks the entire app . The option is a workaround, not a general solution.

The exports map problem. A shared package that needs to be consumed by both a Vite frontend and a Node.js backend can have a complex exports map. If the package exports server-only code through its main entry, the Vite frontend will try to bundle that code, and the build fails on the server-only dependency. If the package adds a separate exports entry for the backend code, Vite may fail to resolve the main entry because the exports map is now more restrictive . The pattern that works is to keep the main exports clean and use relative paths from the dist directory for the backend-only code, rather than adding complex exports entries .

The duplicate React problem. A monorepo with multiple React applications and shared component packages can end up with more than one copy of React. The symptom is a hook error that only appears when a component from a shared package is rendered. The fix is to declare React as a peer dependency in the shared package and to configure Vite to deduplicate React with resolve.dedupe: ['react', 'react-dom'] . The deduplication is the mechanism that ensures the shared package uses the application’s React, not its own.


a. The workspace structure and the root configuration

The workspace structure is created by the package manager. For npm and yarn, the workspaces field in the root package.json lists the workspace directories. For pnpm, the pnpm-workspace.yaml file lists them.

// package.json — the npm/yarn workspaces field
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ]
}
# pnpm-workspace.yaml — the pnpm workspaces
packages:
  - 'apps/*'
  - 'packages/*'

The root package.json does not have a dependencies section for the workspace packages. The workspace packages declare their own dependencies, and the package manager hoists the shared dependencies to the root node_modules where possible. The private: true field prevents the root from being published.

The Vite configuration lives in the application that consumes the linked package, not in the root. The application’s vite.config.ts is where the optimizeDeps, build.commonjsOptions, and resolve.dedupe options are set.

// apps/web/vite.config.ts — the linked package configuration
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    // The linked CommonJS package must be pre-bundled.
    include: ['@myorg/ui-lib'],
    // The linked ESM package can be excluded.
    exclude: ['@myorg/utils'],
  },
  build: {
    commonjsOptions: {
      // The CommonJS package must be included in the build transform.
      include: [/@myorg\/ui-lib/, /node_modules/],
    },
  },
  resolve: {
    // Deduplicate React so the shared package uses the app's copy.
    dedupe: ['react', 'react-dom'],
  },
});

The optimizeDeps.include array accepts the package names. The build.commonjsOptions.include array accepts regular expressions, and the pattern must match the package path. The resolve.dedupe array is the React deduplication mechanism .


b. The package exports and the multi-entry pattern

A shared package that serves both the frontend and the backend needs a clean exports map. The pattern that works is to keep the main entry clean and expose the backend-only code through a separate entry that is imported by relative path, not through the exports map .

// packages/core/package.json — the exports map
{
  "name": "@myorg/core",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0"
  }
}

The exports map declares only the main entry. The backend-only code is built to a separate file and imported by relative path from the backend application.

// apps/api/src/server.js — the backend-only import
// The backend uses a relative path into the package's dist directory.
// This bypasses the exports map and does not affect the frontend resolution.
import { createRouter } from '../../packages/core/dist/trpc/router.js';

The frontend imports the main entry normally, and the Vite resolution works because the exports map is simple and the main entry is ESM.

// apps/web/src/app.jsx — the frontend import
import { Button } from '@myorg/core';

The two applications consume different parts of the same package without interfering with each other. The frontend’s Vite build does not see the backend-only code, and the backend does not need the frontend’s ESM resolution .


Complete Example Session

// ============================================
// PART 1: THE WORKSPACE STRUCTURE
// ============================================

// my-monorepo/
// ├── package.json          (workspaces: ["apps/*", "packages/*"])
// ├── apps/
// │   ├── web/              (Vite + React)
// │   └── api/              (Node.js)
// └── packages/
//     └── ui/               (the shared package)

// ============================================
// PART 2: THE SHARED PACKAGE
// ============================================

// packages/ui/package.json
{
  "name": "@myorg/ui",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0"
  }
}

// The package is ESM and declares React as a peer dependency.
// The peer dependency is the mechanism that prevents a duplicate React.

// ============================================
// PART 3: THE VITE CONFIGURATION
// ============================================

// apps/web/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    // The shared package is ESM and built.
    // Excluding it lets the browser handle it natively.
    exclude: ['@myorg/ui'],
  },
  resolve: {
    // Deduplicate React across the workspace.
    dedupe: ['react', 'react-dom'],
  },
});

// ============================================
// PART 4: THE COMMONJS VARIANT
// ============================================

// If the shared package is CommonJS:
export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    // Pre-bundle the linked CommonJS package.
    include: ['@myorg/ui'],
  },
  build: {
    commonjsOptions: {
      // Include the package in the build transform.
      include: [/@myorg\/ui/, /node_modules/],
    },
  },
  resolve: {
    dedupe: ['react', 'react-dom'],
  },
});

// ============================================
// PART 5: THE YARN SYMLINK WORKAROUND
// ============================================

// If the linked package is not resolved with yarn:
export default defineConfig({
  resolve: {
    // Follow the symlinks to the real path.
    // This is the yarn workspace workaround.
    preserveSymlinks: true,
  },
});

// The option can break hoisted dependencies.
// Use it only if the linked package is not resolved otherwise.

// ============================================
// PART 6: THE MULTI-ENTRY PACKAGE
// ============================================

// packages/core/package.json — the clean exports map
{
  "name": "@myorg/core",
  "exports": {
    ".": "./dist/index.js"
  }
}

// The backend imports the internal path directly.
import { createRouter } from '@myorg/core/dist/trpc/router.js';

// The frontend imports the main entry.
import { Button } from '@myorg/core';

// The two imports do not interfere.

// ============================================
// PART 7: THE SCRIPTS
// ============================================

// package.json (root)
{
  "scripts": {
    "dev": "npm run dev --workspace=apps/web",
    "build": "npm run build --workspace=apps/web",
    "dev:ui": "npm run dev --workspace=packages/ui"
  }
}

// The workspace flag runs the script in the specified workspace.

// ============================================
// PART 8: THE VERIFICATION
// ============================================

// 1. Install the dependencies at the root.
// npm install
//
// 2. Start the Vite app.
// npm run dev
//
// 3. Import the shared package in the app.
// import { Button } from '@myorg/ui';
//
// 4. Verify the component renders without a duplicate React error.
//
// 5. Restart the dev server with --force if the linked dependency
//    configuration changed.
// npm run dev -- --force

// ============================================
// PART 9: THE COMMON ERRORS
// ============================================

// "Failed to resolve import '@myorg/ui'"
// → The package is not linked. Run the install at the root.
//
// "Uncaught SyntaxError: The requested module does not provide an export"
// → The package is CommonJS and not pre-bundled. Add it to optimizeDeps.include.
//
// "Invalid hook call"
// → Duplicate React. Add resolve.dedupe and peerDependencies.
//
// "ERR_PACKAGE_PATH_NOT_EXPORTED"
// → The import path is not in the exports map.
//   Use a relative path from dist or add the path to exports.

// ============================================
// PART 10: THE CHECKLIST
// ============================================

// [ ] The root package.json has the workspaces field.
// [ ] The shared package declares React as a peer dependency.
// [ ] The Vite config deduplicates React.
// [ ] The linked CommonJS package is in optimizeDeps.include.
// [ ] The linked CommonJS package is in build.commonjsOptions.include.
// [ ] The linked ESM package is in optimizeDeps.exclude.
// [ ] The shared package exports map is clean.
// [ ] The backend-only code is imported by relative path.
// [ ] The dev server is restarted with --force after config changes.

The ten parts covered the workspace structure, the shared package, the Vite configuration, the CommonJS variant, the yarn symlink workaround, the multi-entry package, the scripts, the verification, the common errors, and the checklist. The through-line is that Vite treats linked packages as source, and the configuration tells it how to handle them.


Quick Reference

The linked package configuration

Package typeoptimizeDepsbuild.commonjsOptions
CommonJSinclude: ['pkg']include: [/pkg/, /node_modules/]
ESMexclude: ['pkg']Not needed

The React deduplication

MechanismConfiguration
Peer dependency`”peerDependencies”: { “react”: “^18
Vite deduperesolve.dedupe: ['react', 'react-dom']

The symlink workaround

OptionUse caseRisk
resolve.preserveSymlinks: trueYarn workspace links not resolvedBreaks hoisted deps in some setups

The multi-entry package

ImportTargetAffects frontend
@myorg/coredist/index.js (main entry)Yes
@myorg/core/dist/trpc/router.jsBackend-only fileNo

Best Practices

✅ Do This:

// Deduplicate React
resolve: { dedupe: ['react', 'react-dom'] }                       // ✅

// Pre-bundle the linked CommonJS package
optimizeDeps: { include: ['@myorg/ui'] }                          // ✅

// Exclude the linked ESM package
optimizeDeps: { exclude: ['@myorg/utils'] }                       // ✅

// Declare React as a peer dependency
"peerDependencies": { "react": "^18.0.0 || ^19.0.0" }             // ✅

❌ Don’t Do This:

// Use preserveSymlinks as a general fix
resolve: { preserveSymlinks: true } // breaks hoisted deps          // ❌

// Leave a linked CommonJS package unconfigured
// The browser fails on the CJS require.                            // ❌

// Add complex exports entries for backend-only code
// Vite resolution breaks on the main entry.                        // ❌

Common Pitfalls

PitfallWhy It HappensFix
The linked package is not foundThe install was not run at the rootRun npm install at the root
The browser fails on a CJS importThe linked package is CommonJSAdd it to optimizeDeps.include
“Invalid hook call”Duplicate ReactAdd resolve.dedupe and peer deps
The backend code is bundled in the frontendThe main entry exports server-only codeKeep the exports map clean and use relative paths
The changes are not picked upThe linked package config changedRestart with --force

Real-World Examples

1. The workspaces field

"workspaces": ["apps/*", "packages/*"]

2. The pnpm workspace

packages: ['apps/*', 'packages/*']

3. The CJS include

optimizeDeps: { include: ['@myorg/ui'] }

4. The ESM exclude

optimizeDeps: { exclude: ['@myorg/utils'] }

5. The build include

commonjsOptions: { include: [/@myorg\/ui/, /node_modules/] }

6. The React dedupe

resolve: { dedupe: ['react', 'react-dom'] }

7. The peer dependency

"peerDependencies": { "react": "^18.0.0 || ^19.0.0" }

8. The multi-entry import

import { createRouter } from '@myorg/core/dist/trpc/router.js';

9. The workspace script

"dev": "npm run dev --workspace=apps/web"

10. The force restart

npm run dev -- --force

Visual

The Linked Package Resolution

┌──────────────────────────────────────────────────────────────┐
│  VITE TREATS LINKED PACKAGES AS SOURCE, NOT DEPENDENCIES      │
│                                                              │
│  node_modules (hoisted)      linked package (workspace)     │
│  ┌─────────────────────┐    ┌─────────────────────┐         │
│  │ Pre-bundled by Vite │    │ Treated as source   │         │
│  │ optimizeDeps        │    │ Transformed by Vite │         │
│  │                     │    │                     │         │
│  │ No config needed    │    │ Config needed:      │         │
│  │                     │    │ CJS → include       │         │
│  │                     │    │ ESM → exclude       │         │
│  └─────────────────────┘    └─────────────────────┘         │
└──────────────────────────────────────────────────────────────┘

The CJS vs ESM Configuration

┌──────────────────────────────────────────────────────────────┐
│  THE CONFIGURATION DEPENDS ON THE PACKAGE FORMAT              │
│                                                              │
│  CommonJS linked package      ESM linked package             │
│  ┌─────────────────────┐     ┌─────────────────────┐        │
│  │ optimizeDeps:       │     │ optimizeDeps:       │        │
│  │   include: ['pkg']  │     │   exclude: ['pkg']  │        │
│  │                     │     │                     │        │
│  │ build:              │     │ No build config     │        │
│  │   commonjsOptions:  │     │ needed              │        │
│  │     include: [/pkg/]│     │                     │        │
│  └─────────────────────┘     └─────────────────────┘        │
└──────────────────────────────────────────────────────────────┘

The Duplicate React Problem

┌──────────────────────────────────────────────────────────────┐
│  WHY THE HOOK ERROR HAPPENS                                   │
│                                                              │
│  Without dedupe:              With dedupe:                   │
│  ┌─────────────────────┐     ┌─────────────────────┐        │
│  │ App                 │     │ App                 │        │
│  │ React 19            │     │ React 19            │        │
│  │                     │     │                     │        │
│  │ Shared package      │     │ Shared package      │        │
│  │ React 19 (own copy) │     │ React 19 (deduped)  │        │
│  │                     │     │                     │        │
│  │ Two React instances │     │ One React instance  │        │
│  │ Hook error          │     │ No error            │        │
│  └─────────────────────┘     └─────────────────────┘        │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Workspace declarationworkspaces field (npm/yarn) or pnpm-workspace.yaml
Linked CJS packageoptimizeDeps.include + build.commonjsOptions.include
Linked ESM packageoptimizeDeps.exclude
React deduplicationresolve.dedupe: ['react', 'react-dom']
Peer dependencyReact in the shared package’s peerDependencies
Symlink workaroundresolve.preserveSymlinks: true (use with caution)
Force restart--force after config changes

Key takeaways:

  • Vite treats linked workspace packages as source code, not as dependencies. The dependency pre-bundling that handles node_modules does not apply to a linked package. The configuration must tell Vite how to handle the package based on its format .
  • A linked CommonJS package needs two configuration entries. The optimizeDeps.include array pre-bundles the package during development, and the build.commonjsOptions.include array includes it in the build transform. Without both, the package fails in one mode or the other .
  • A linked ESM package can be excluded from pre-bundling. The optimizeDeps.exclude array tells Vite to let the browser handle the ESM package natively. The exclusion works because the package is already ESM and does not need transformation .
  • React must be deduplicated. A shared package that declares its own React dependency creates a second React instance, and the hooks fail. The fix is to declare React as a peer dependency in the shared package and to add resolve.dedupe: ['react', 'react-dom'] to the Vite config .
  • The preserveSymlinks option is a workaround, not a solution. It resolves the yarn workspace symlink issue, but it breaks hoisted dependencies in some projects. Use it only when the linked package is not resolved otherwise, and expect the changes to require a --force restart .

Remember: A Vite monorepo is a workspace managed by the package manager, with a Vite config in each application that consumes a linked package. The config tells Vite how to handle the package: pre-bundle it if it is CommonJS, exclude it if it is ESM, and deduplicate React if the package declares it. The package’s exports map must be clean, and the backend-only code must be imported by relative path. The linked package is source, not a dependency, and the configuration is the mechanism that makes it work.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!