Monorepo Module Resolution#
Overview#
In Strapi's pnpm monorepo, correctly resolving shared dependencies (React, Redux, styled-components, etc.) across bundled packages requires explicit Vite and Webpack aliases. Without them, pnpm's strict, isolated node_modules layout prevents plugin-emitted chunks from finding modules that are only available in the admin package's closure.
The primary mechanism is getModulePath(mod) in packages/core/strapi/src/node/core/resolve-module.ts. It calls require.resolve() to find a module's entry point, then uses readPkgUp.sync() to walk up to the package root, returning a directory path suitable for use as a bundler alias.
Why Aliases Are Needed#
pnpm installs each package into its own scoped directory and uses symlinks rather than hoisting everything to a flat top-level node_modules. This means a dynamically loaded plugin chunk can only see modules that are direct dependencies of that plugin — not the admin's pinned versions — unless those versions are forced via bundler aliases.
Both the Webpack config and the Vite config set resolve.alias entries for core shared singletons: react, react-dom, react-router-dom, styled-components, @reduxjs/toolkit, react-redux, @strapi/design-system, @radix-ui/react-tooltip, and lodash. The Webpack config comment notes the intent explicitly: "Force single instance so plugin custom field chunks inherit root DesignSystemProvider context" .
The Context-Constrained require.resolve() Problem#
Root Cause#
A bare require.resolve(mod) call resolves from the calling module's file location and walks up the directory tree. In a pnpm workspace this means starting from @strapi/strapi's package directory, not from @strapi/admin — the package that actually pins the expected versions.
When another workspace package depends on a different major of one of these singletons (e.g., @reduxjs/toolkit@^2), pnpm hoists that version to .pnpm/node_modules/. A bare resolve from @strapi/strapi's location finds the hoisted major before the admin's pinned one.
Concrete Regression (5.48.1)#
PR #26249 (merged 2026-06-15) added react-redux and @reduxjs/toolkit to the Vite alias/dedupe/optimizeDeps lists to fix a Redux context duplication issue during v4→v5 upgrades. The original getModulePath implementation used a bare require.resolve():
// packages/core/strapi/src/node/core/resolve-module.ts (pre-fix)
const modulePath = require.resolve(mod); // unconstrained — walks from @strapi/strapi
This caused a reported regression in 5.48.1: in any pnpm workspace that also contained a package depending on RTK 2.x, getModulePath('@reduxjs/toolkit') resolved to the hoisted @reduxjs/toolkit@2.x instead of the 1.9.7 pinned by @strapi/admin. The entire admin Vite build was force-aliased to the wrong major, causing RTK 2's configureStore to throw "Duplicate middleware references found when creating the store" and the admin panel to render blank .
The Fix: Scope Resolution to @strapi/admin's Closure#
PR #26756 fixes the regression by replacing bare require.resolve() with resolveFrom (the resolve-from library) scoped to @strapi/admin's package directory:
// packages/core/strapi/src/node/core/resolve-module.ts (post-fix)
const getAdminPkgDir = (): string => {
if (!adminPkgDir) {
adminPkgDir = path.dirname(require.resolve('@strapi/admin/package.json'));
}
return adminPkgDir;
};
export const getModulePath = (mod: string): string => {
const modulePath = resolveFrom(getAdminPkgDir(), mod); // scoped to admin's closure
const pkg = readPkgUp.sync({ cwd: path.dirname(modulePath) });
return pkg ? path.dirname(pkg.path) : modulePath;
};
By starting the resolution walk from @strapi/admin's directory, getModulePath now finds only the versions that @strapi/admin declares as dependencies — regardless of what other workspace packages hoist. The same PR also centralizes the list of alias modules into admin-vite-alias-modules.ts and introduces buildAdminViteResolveAliases() in admin-vite-aliases.ts as the single source of truth .
Linked Package Detection#
A secondary use of getModulePath is detecting locally-linked packages (via portal:, file:, or yarn link). linked-packages.ts checks whether the resolved path contains a node_modules segment:
export const isPackageLinked = (mod: string): boolean => {
const pkgRoot = getModulePath(mod);
const pathSegments = pkgRoot.split(path.sep);
return !pathSegments.includes('node_modules');
};
This is used by getLinkedDesignSystemPath() to enable live-reload aliasing when @strapi/design-system is linked locally during development.
Monorepo Dev Aliases#
When running inside the Strapi source monorepo itself, an additional layer of aliases maps published package entry points to their TypeScript source directories. These are defined in aliases.ts and applied by getMonorepoAliases() — but only when loadStrapiMonorepo() detects the workspace (by finding a package.json with isStrapiMonorepo: true). These aliases are distinct from the cross-package singleton aliases above and exist purely to speed up hot-reload by skipping the build step for internal packages.
Key Files#
| File | Purpose |
|---|---|
resolve-module.ts | Core getModulePath() utility |
vite/config.ts | Vite resolve aliases using getModulePath |
webpack/config.ts | Webpack resolve aliases using getModulePath |
linked-packages.ts | Linked package detection |
aliases.ts | Dev-mode monorepo source aliases |
| Issue #26755 | Bug report: hoisted RTK 2.x in pnpm workspace |
| PR #26249 | Added Redux to Vite alias/dedupe (introduced regression) |
| PR #26756 | Fix: scope getModulePath to @strapi/admin closure |