Workspaces, react and vite. A real-world case study for managing duplicate libraries.

Published: (December 4, 2025 at 02:03 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

Introduction

When organizing a project using npm workspaces, the typical structure consists of a single root package that orchestrates multiple local packages and applications:

my-workspace
│   package.json
├─ apps
│   ├─ client
│   └─ server
└─ packages
    ├─ package-a
    │   ├─ src
    │   └─ package.json
    └─ package-b
        ├─ src
        └─ package.json

This architecture promotes modularity and code reuse, but managing shared dependencies can introduce complexity in module resolution, leading to unexpected runtime errors that are difficult to diagnose.

A common symptom is the error:

Cannot destructure property 'basename' of 'w.useContext(...)' as it is null

This indicates that a component cannot access the router context even though a Router provider is present. The root cause is often dependency duplication.

In this article we analyze:

  • How npm handles version resolution
  • How Vite handles dependency resolution
  • How to implement a targeted deduplication strategy using Vite

Dependency Configuration

package-a

{
  "dependencies": {
    "react-router-dom": "^6.21.1"
  }
}

package-b

{
  "dependencies": {
    "react-router": "6.30.1",
    "react-router-dom": "6.30.1"
  }
}

react-router-dom depends on react-router:

{
  "name": "react-router-dom",
  "version": "6.30.1",
  "dependencies": {
    "react-router": "6.30.1"
  }
}

Installation Result

When npm installs the workspace, it tries to hoist dependencies to the workspace root while respecting semantic version constraints:

  1. package-a is processed first. The caret range ^6.21.1 resolves to the latest compatible version, e.g., 6.30.2. This version is installed at the workspace root. Consequently, react-router-dom@6.30.2 (which depends on react-router@6.30.2) is also hoisted to the root.

  2. package-b is processed next. It pins react-router and react-router-dom to 6.30.1. Because these versions differ from the hoisted ones, npm installs a separate copy inside packages/package-b/node_modules, along with its own react-router@6.30.1.

The resulting on‑disk structure contains two distinct versions of both libraries:

my-workspace/
├─ node_modules/
│   ├─ react-router@6.30.2
│   └─ react-router-dom@6.30.2
└─ packages/
    └─ package-b/
        └─ node_modules/
            ├─ react-router@6.30.1
            └─ react-router-dom@6.30.1

Role of the Bundler

In modern frontend development, the bundler (Webpack, Rollup, Vite, etc.) traverses the application’s dependency graph, resolves each import, and bundles the code for the browser.

If two different physical copies of a library are present, the bundler will include both versions in the final bundle. This leads to:

  • Increased bundle size
  • Inconsistent behavior when the same library is used at different versions across the codebase

For react-router, using mismatched versions breaks the context system, producing the runtime error shown earlier.


Deduplication Strategies

1. npm overrides

You can force a single version across the workspace by adding an overrides field in the root package.json:

{
  "overrides": {
    "react-router": "6.30.1",
    "react-router-dom": "6.30.1"
  }
}

This approach works but may be too coarse for some projects.

2. Vite resolve.dedupe

Vite provides a native deduplication option:

// vite.config.ts
export default {
  resolve: {
    dedupe: ['react-router', 'react-router-dom']
  }
}

resolve.dedupe forces Vite to resolve the listed packages to the copy found at the project root. In the scenario above, the root contains version 6.30.2, so Vite would still bundle the newer version, ignoring the pinned 6.30.1 inside package-b.

3. Custom Vite Plugin for Granular Control

When you need fine‑grained control—e.g., always resolve to the version inside a specific package—you can implement a Vite plugin that intercepts module resolution and redirects it to a chosen location.

import fs from 'fs';
import path from 'path';
import { PluginOption } from 'vite';

/**
 * Plugin to force deduplication of specific packages toward
 * a version contained in a defined relative path.
 *
 * @param dedupe - Array of package names to deduplicate (e.g. ['react-router'])
 * @param packagePath - Relative path to the package to use as "source of truth"
 */
const enhancedDedupePlugin = ({
  dedupe,
  packagePath,
}: {
  dedupe: string[];
  packagePath: string;
}): PluginOption => ({
  name: 'vite-plugin-enhanced-dedupe',
  enforce: 'pre', // Run before Vite's default resolver
  resolveId(source, _importer) {
    // Only process whitelisted packages
    if (!dedupe.includes(source)) return null;

    // Build absolute path to the target node_modules
    const targetBase = path.resolve(__dirname, packagePath, 'node_modules', source);
    if (!fs.existsSync(targetBase)) return null;

    // Locate the package's entry point
    const pkgJsonPath = path.join(targetBase, 'package.json');
    if (!fs.existsSync(pkgJsonPath)) return null;

    try {
      const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
      const entry = pkg.module || pkg.main || 'index.js';
      return path.join(targetBase, entry);
    } catch {
      return null; // Fallback to default resolution on error
    }
  },
});

export default {
  plugins: [
    enhancedDedupePlugin({
      dedupe: ['react-router', 'react-router-dom'],
      packagePath: '../package-a' // adjust to the package you trust
    })
  ]
};

This plugin:

  • Checks if the imported module is in the dedupe whitelist.
  • Resolves it to the node_modules folder of the specified packagePath.
  • Falls back to Vite’s default resolution if the target package cannot be found.

By pointing packagePath to the package that contains the desired version (e.g., ../package-a), you can ensure that all imports of react-router and react-router-dom resolve to the same physical copy, eliminating duplication and the associated runtime errors.


Conclusion

  • npm workspaces can hoist multiple versions of the same library when version constraints differ.
  • Vite, by default, bundles each physical copy it encounters, which can cause both size bloat and functional bugs (e.g., broken React context).
  • Simple solutions like overrides or resolve.dedupe may not provide the granularity required for complex monorepos.
  • A custom Vite plugin that redirects resolution to a chosen package offers precise control over deduplication, ensuring a single instance of critical libraries such as react-router across the entire workspace.
Back to Blog

Related posts

Read more »