Migrate to TS solution setup Migrate this Nx workspace from TypeScript path aliases to workspaces and project references.
- Enable package manager workspaces
- Update root TypeScript configuration
- Create project package.json files
- Update project TypeScript configurations
- Update bundler configurations
- Verify the migration
Analyze my repository and walk me through each step. Adapt the instructions to my specific workspace setup.
Guide: https://canary.nx.dev/docs/technologies/typescript/guides/switch-to-workspaces-project-references.md
Migrate this Nx workspace from TypeScript path aliases to workspaces and project references.
- Enable package manager workspaces
- Update root TypeScript configuration
- Create project package.json files
- Update project TypeScript configurations
- Update bundler configurations
- Verify the migration
Analyze my repository and walk me through each step. Adapt the instructions to my specific workspace setup.
Guide: https://canary.nx.dev/docs/technologies/typescript/guides/switch-to-workspaces-project-references.md
Migrate this Nx workspace from TypeScript path aliases to workspaces and project references.
- Enable package manager workspaces
- Update root TypeScript configuration
- Create project package.json files
- Update project TypeScript configurations
- Update bundler configurations
- Verify the migration
Analyze my repository and walk me through each step. Adapt the instructions to my specific workspace setup.
Guide: https://canary.nx.dev/docs/technologies/typescript/guides/switch-to-workspaces-project-references.md
To get the performance benefits of TypeScript project references, use package manager workspaces for project linking instead of TypeScript path aliases.
Enable package manager workspaces
Section titled “Enable package manager workspaces”Configure your package manager to use workspaces for project linking.
{ "workspaces": ["apps/*", "libs/*"]}The workspaces property tells npm to look for package.json files in the specified folders. Running npm install installs all project dependencies in the root node_modules and symlinks the local projects so they can be imported like npm packages.
Include local libraries in devDependencies of the consuming project's package.json with * as the version. * tells npm to use whatever local version is available. This applies to both buildable and non-buildable libraries.
{ "devDependencies": { "@my-org/some-project": "*" }}{ "workspaces": ["apps/*", "libs/*"]}The workspaces property tells yarn to look for package.json files in the specified folders. Running yarn installs all project dependencies in the root node_modules and symlinks the local projects so they can be imported like npm packages.
Include local libraries in devDependencies of the consuming project's package.json with workspace:* as the version. workspace:* tells yarn the project is in the same repository and not an npm package.
{ "devDependencies": { "@my-org/some-project": "workspace:*" }}{ "workspaces": ["apps/*", "libs/*"]}The workspaces property tells bun to look for package.json files in the specified folders. Running bun install installs all project dependencies in the root node_modules and symlinks the local projects so they can be imported like npm packages.
Include local libraries in devDependencies of the consuming project's package.json with workspace:* as the version. workspace:* tells bun the project is in the same repository and not an npm package.
{ "devDependencies": { "@my-org/some-project": "workspace:*" }}packages: - 'apps/*' - 'libs/*'The packages property tells pnpm to look for package.json files in the specified folders. Running pnpm install installs all project dependencies in the root node_modules.
Include local libraries in devDependencies of the consuming project's package.json with workspace:* as the version. workspace:* tells pnpm the project is in the same repository and not an npm package.
{ "devDependencies": { "@my-org/some-project": "workspace:*" }}Update .gitignore
Section titled “Update .gitignore”With TypeScript project references, build artifacts are output within each project's directory rather than a shared root dist folder. Add these patterns to your root .gitignore:
out-tscdisttest-outputUse root-level entries (not path-prefixed like /dist) so they match in nested project directories.
Update root TypeScript configuration
Section titled “Update root TypeScript configuration”The root tsconfig.base.json should contain only compilerOptions, not top-level properties like include, exclude, or files. If yours has any of these, move them to the appropriate project-level tsconfig files before proceeding.
Set compilerOptions.composite to true. If declaration is explicitly set to false anywhere in the config chain, remove it — composite requires declarations and will fail with TS6304 if declaration is disabled. It can be omitted entirely since it defaults to true when composite is true. Delete compilerOptions.paths entirely (not set to {}), and remove compilerOptions.rootDir and compilerOptions.baseUrl if present. These aren't needed in TS solution mode since imports resolve through node_modules symlinks.
If moduleResolution is set to "node" (Node10), change it to "bundler". The "node" setting does not support exports fields in package.json, so TypeScript will fail to resolve workspace libraries after removing path aliases. The "bundler" setting supports exports while still allowing extensionless relative imports.
If you changed moduleResolution to "bundler" (or are using "node16"/"nodenext"), add customConditions with a condition unique to your organization (e.g. @myorg/source). This tells TypeScript to prefer the matching condition in each library's exports field, resolving workspace dependencies to source .ts files during development without requiring a build step first. customConditions is not supported with moduleResolution: "node" (Node10) — if you must keep "node", skip this and the corresponding @myorg/source condition in library exports. Also add declarationMap: true for better editor navigation across project boundaries and isolatedModules: true for compatibility with bundlers and SWC.
Copy the paths entries before deleting them. You'll need them as a reference for creating project package.json files and references in tsconfig.json.
{ "compilerOptions": { "allowJs": false, "allowSyntheticDefaultImports": true, // ... "paths": { "@myorg/utils": ["libs/utils/src/index.ts"], "@myorg/ui": ["libs/ui/src/index.ts"], }, },}{ "compilerOptions": { "composite": true, "moduleResolution": "bundler", "declarationMap": true, "isolatedModules": true, "customConditions": ["@myorg/source"], // declaration defaults to true when composite is true // paths, rootDir, baseUrl, and module have been removed "allowJs": false, "allowSyntheticDefaultImports": true, },}The root tsconfig.json should extend tsconfig.base.json, include no files, and list references for every project so editor tooling works correctly.
{ "extends": "./tsconfig.base.json", "files": [], // intentionally empty}{ "extends": "./tsconfig.base.json", "files": [], // intentionally empty "references": [ { "path": "./libs/utils", }, { "path": "./libs/ui", }, // Generated projects are added here automatically ],}Register Nx TypeScript plugin
Section titled “Register Nx TypeScript plugin”Install @nx/js and register @nx/js/typescript as a plugin in nx.json:
{ "plugins": [ { "plugin": "@nx/js/typescript", "options": { "typecheck": { "targetName": "typecheck", }, "build": { "targetName": "build", "configName": "tsconfig.lib.json", "buildDepsName": "build-deps", "watchDepsName": "watch-deps", }, }, }, ],}This plugin registers a sync generator that automatically maintains project references across the workspace.
Update build targets
Section titled “Update build targets”The @nx/js/typescript plugin infers both typecheck and build targets from tsconfig.lib.json, so explicit @nx/js:tsc build targets in project.json (or package.json) should be removed. Keeping both the explicit target and the inferred one leads to duplicated configuration — the output path, entry point, and tsconfig are already defined in tsconfig.lib.json and package.json, so the project.json target becomes redundant.
{ "name": "ui", "targets": { "build": { "executor": "@nx/js:tsc", "outputs": ["{options.outputPath}"], "options": { "outputPath": "dist/libs/ui", "main": "libs/ui/src/index.ts", "tsConfig": "libs/ui/tsconfig.lib.json", }, }, },}{ "name": "ui", // build target removed — typecheck and build are inferred by @nx/js/typescript plugin}If the only remaining property in project.json is name, delete the file entirely. Nx infers the project name from package.json, so project.json is only needed when it contains custom target configuration, tags, or implicitDependencies that aren't expressed elsewhere.
Verify inferred targets
Section titled “Verify inferred targets”After removing explicit build targets, confirm the plugin infers the correct targets for each project:
nx show project my-libFor non-buildable libraries (whose exports point directly to source .ts files), you should see an inferred typecheck target. For buildable libraries, you should see both typecheck and build targets. The plugin infers a build target when package.json entry points (exports, main, or module) resolve to paths inside the tsconfig's outDir. If a buildable library is missing its build target, check that package.json entry points point inside outDir (e.g. "main": "./dist/index.js" with outDir: "./dist") and that rootDir is set to ./src (see Update individual project TypeScript configuration).
Create individual project package.json files
Section titled “Create individual project package.json files”Every project needs a package.json for workspaces to link projects as dependencies. Without it, your package manager won't create the node_modules symlinks that replace the old TypeScript path aliases. For applications, a name field is sufficient since apps are consumed by dev servers and bundlers, not imported by other projects. Use the same name as the existing project.json so that e2e configurations (e.g. Playwright's webServer.command: 'nx run myapp:preview') continue to work without changes. For libraries, add an exports field that maps to the source entry points so that TypeScript and bundlers can resolve imports to the library's source code.
The examples below use * for workspace dependency versions. If you use yarn, pnpm, or bun, replace * with workspace:* as shown in the package manager setup above.
{ "name": "@myorg/ui", "devDependencies": { "@myorg/utils": "*" }, "exports": { "./package.json": "./package.json", ".": { "@myorg/source": "./src/index.ts", "types": "./src/index.ts", "import": "./src/index.ts", "default": "./src/index.ts" } }}The @myorg/source condition matches the customConditions in tsconfig.base.json, so TypeScript resolves to source during development. The types condition points to source so TypeScript resolves correctly within the monorepo. The import/default conditions resolve to built output for production consumers.
{ "name": "@myorg/ui", "type": "module", "main": "./dist/index.js", "types": "./src/index.ts", "devDependencies": { "@myorg/utils": "*" }, "exports": { "./package.json": "./package.json", ".": { "@myorg/source": "./src/index.ts", "types": "./src/index.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }}{ "name": "@myorg/my-app", "devDependencies": { "@myorg/ui": "*", "@myorg/utils": "*" }}After creating or updating project package.json files, run your package manager's install command again (e.g. npm install) so that new workspace symlinks are created in node_modules.
Package names with multiple slashes
Section titled “Package names with multiple slashes”A package.json name can only have one / (the scope separator), so a name like @myorg/shared/ui is invalid. This is more restrictive than TypeScript path aliases, so you'll need to flatten any nested aliases. You have two main options:
- Drop the original scope:
@myorg/shared/uibecomes@shared/ui - Combine scope segments with a dash:
@myorg/shared/uibecomes@myorg-shared/ui
| Old path alias | Option 1 (drop scope) | Option 2 (combine with dash) |
|---|---|---|
@myorg/shared/ui | @shared/ui | @myorg-shared/ui |
@myorg/foo/bar | @foo/bar | @myorg-foo/bar |
@myorg/foo/bar/baz | @foo/bar-baz | @myorg-foo/bar-baz |
Choose a convention and apply it consistently. Update all import statements to match the new names.
If multiple path aliases point to different entry points within the same library (e.g. @myorg/utils and @myorg/utils/testing), you could use the exports field to map multiple entry points from a single package:
{ "name": "@myorg/utils", "exports": { "./package.json": "./package.json", ".": { "types": "./src/index.ts", "default": "./src/index.ts" }, "./testing": { "types": "./src/testing.ts", "default": "./src/testing.ts" } }}However, this approach requires consolidating code from separate libraries into one, which adds complexity. For the initial migration, prefer the renaming strategies above and tackle library consolidation separately.
Update individual project TypeScript configuration
Section titled “Update individual project TypeScript configuration”Each project's tsconfig.json should extend tsconfig.base.json and list references to its dependencies. If the project tsconfig.json has compilerOptions, move them into the solution files (any tsconfig.*.json files, such as tsconfig.lib.json/tsconfig.app.json and tsconfig.spec.json), merging with any existing options. Then remove compilerOptions from the project tsconfig.json.
When merging, also remove module and moduleResolution from individual project tsconfigs. Let these inherit from tsconfig.base.json for consistency. If typecheck or build fails for specific projects after this change, add module and moduleResolution back to that project's solution files with the values it needs.
The project tsconfig.json provides your IDE with references to the tsconfig.*.json files that define compilation settings. tsconfig.spec.json handles test files, and tsconfig.lib.json (or tsconfig.app.json) handles production code.
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "module": "commonjs", "strict": true, "forceConsistentCasingInFileNames": true, }, "files": [], "include": [], "references": [ { "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }, ],}{ "extends": "../../tsconfig.base.json", "files": [], // intentionally empty "references": [ // Project dependencies are added by nx sync { "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }, ],}Each solution file (tsconfig.lib.json, tsconfig.app.json, tsconfig.spec.json, or any other tsconfig.*.json) should extend tsconfig.base.json directly, not the project's tsconfig.json. This is because tsconfig.json references tsconfig.spec.json, and keeping test files unreferenced from the lib/app config makes typecheck and build faster.
The solution files that handle production code (tsconfig.lib.json or tsconfig.app.json) also need references to the solution files of their dependency projects. These are added automatically by nx sync. Each outDir must be unique across solution files so cached outputs don't interfere with each other.
{ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true, "types": ["node"], }, "include": ["src/**/*.ts"], "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],}{ "extends": "../../tsconfig.base.json", "compilerOptions": { // Merged from project tsconfig.json (module/moduleResolution removed) "strict": true, "forceConsistentCasingInFileNames": true, "types": ["node"], // outDir can be anything — non-buildable libraries aren't compiled "outDir": "./out-tsc/lib", }, "include": ["src/**/*.ts"], "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], "references": [ // References to dependency projects — added by nx sync // e.g. if ui depends on utils: { "path": "../utils/tsconfig.lib.json" }, ],}{ "extends": "../../tsconfig.base.json", "compilerOptions": { // Merged from project tsconfig.json (module/moduleResolution removed) "strict": true, "forceConsistentCasingInFileNames": true, "types": ["node"], // outDir matches where package.json entry points resolve — plugin infers build "outDir": "./dist", "rootDir": "./src", }, "include": ["src/**/*.ts"], "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], "references": [ // References to dependency projects — added by nx sync { "path": "../utils/tsconfig.lib.json" }, ],}The tsconfig.spec.json doesn't need to reference project dependencies, but it must include a references entry pointing to tsconfig.lib.json (or tsconfig.app.json). Without this, TypeScript will fail with TS6307: File is not listed within the file list of project when spec files import from the project's source code.
{ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", "types": ["jest", "node"], }, "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.d.ts"],}{ "extends": "../../tsconfig.base.json", "compilerOptions": { // Merged from project tsconfig.json (module/moduleResolution removed) "strict": true, "forceConsistentCasingInFileNames": true, // Original options from tsconfig.spec.json "types": ["jest", "node"], // outDir is now local to the project "outDir": "./out-tsc/spec", }, "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.d.ts"], "references": [{ "path": "./tsconfig.lib.json" }],}After updating the tsconfig files, run nx sync to have Nx add the correct project references.
E2E projects
Section titled “E2E projects”End-to-end test projects (Cypress, Playwright) typically have a tsconfig.json but no tsconfig.lib.json. These projects don't need a build target or tsconfig.lib.json. They only need tsconfig.json to extend tsconfig.base.json with references to the projects they test. The @nx/js/typescript plugin adds the correct references via nx sync.
Verify include/exclude patterns
Section titled “Verify include/exclude patterns”Since tsconfig.lib.json and tsconfig.spec.json now extend tsconfig.base.json directly instead of the project's tsconfig.json, any include or exclude patterns that were defined in the project tsconfig.json are no longer inherited. Check that each solution file has the correct include and exclude patterns, and add any that were previously inherited.
For example, if your project tsconfig.json had an include that the solution files relied on:
// libs/ui/tsconfig.json (had include that solution files inherited){ "extends": "../../tsconfig.base.json", "include": ["src/**/*.ts", "src/**/*.tsx"]}
// libs/ui/tsconfig.lib.json (inherited include from tsconfig.json){ "extends": "./tsconfig.json", "exclude": ["src/**/*.spec.ts"]}// libs/ui/tsconfig.json (no include — just references){ "extends": "../../tsconfig.base.json", "files": [], "include": [], "references": [ { "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" } ]}
// libs/ui/tsconfig.lib.json (include must be explicit now){ "extends": "../../tsconfig.base.json", "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["src/**/*.spec.ts"]}Update import paths
Section titled “Update import paths”If your new package names match the old TypeScript path aliases exactly, no import changes are needed. If you renamed packages (e.g. to resolve the multiple-slash restriction), update all import statements across the codebase.
Search and replace in files with these extensions: .ts, .tsx, .js, .jsx, .mts, .cts, .mjs, .cjs.
For example, if you renamed @myorg/shared/ui to @myorg/shared-ui:
import { Button } from '@myorg/shared/ui';import { Button } from '@myorg/shared-ui';Bundler configuration updates
Section titled “Bundler configuration updates”Webpack and Rspack
Section titled “Webpack and Rspack”If your applications use NxAppWebpackPlugin from @nx/webpack or NxAppRspackPlugin from @nx/rspack, no changes are needed. Both plugins detect TS solution workspaces and disable TsconfigPathsPlugin, relying on node_modules symlinks instead.
If your projects use Jest with the @nx/jest resolver (@nx/jest/plugins/resolver), no changes are needed. The resolver falls back to TypeScript module resolution when the default Node resolver fails, finding local projects through node_modules symlinks.
Update vite.config.ts for each Vite project:
- Remove
nxViteTsPathsfromplugins(if present). This plugin resolved TypeScript path aliases in older Nx versions. After migration, Vite built-in resolver finds local projects throughnode_modulessymlinks. Newer Nx versions may not include this plugin. - Add
resolve.conditionswith the same custom condition used intsconfig.base.json(e.g.@myorg/source). This lets Vite resolve workspace libraries to source files during dev server and test runs, matching TypeScript'scustomConditionsbehavior. See Testing without building dependencies for more details on how this works. - Set
build.outDirto./distrelative to the project folder.
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig({ // ... plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])], build: { outDir: '../../dist/libs/ui', // ... },});import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig({ // ... plugins: [react(), nxCopyAssetsPlugin(['*.md'])], resolve: { conditions: ['@myorg/source'], }, build: { outDir: './dist', // ... },});Verify the migration
Section titled “Verify the migration”Run the full target suite to confirm everything works:
nx run-many -t typecheck,build,lint,testFix any issues that come up before continuing. Common problems at this stage include:
Missing
references: Runnx syncto populate project references automatically.TS2307 "Cannot find module": If the error suggests updating to
node16,nodenext, orbundler, yourmoduleResolutionis set to"node"(Node10) which does not support theexportsfield inpackage.json. ChangemoduleResolutionto"bundler"intsconfig.base.json.TS5095
module: "commonjs"incompatible withmoduleResolution: "bundler": Project-level tsconfigs (especially spec and e2e files) that setmodule: "commonjs"will conflict withmoduleResolution: "bundler"inherited from the base config. RemovemoduleandmoduleResolutionfrom these files — Jest and Vitest use their own transformers and don't rely on the tsconfigmodulesetting for execution.Import path errors: Ensure all import paths match the
namefield in each project'spackage.json.Missing
exportsinpackage.json: Verify each library has anexportsfield. Non-buildable libraries should point all conditions to source (e.g../src/index.ts). Buildable libraries should pointimport/defaultto compiled output (e.g../dist/index.js) but keeptypespointing to source so TypeScript resolves correctly within the monorepo.@nx/dependency-checkslint errors: For buildable libraries, this lint rule requires runtime workspace dependencies to be independencies(notdevDependencies). If the rule reports a missing dependency for a workspace library that is imported in production code, move it fromdevDependenciestodependencies.@nx/dependency-checksfalse positives for dev tool packages: Buildable libraries using this rule may report missing dependencies for packages likevitest,@nx/vite, orjestafter migration. This happens because the rule scans config files likevitest.config.mtsand detects imports that shouldn't be declared as project-level dependencies — they belong in the rootpackage.json. Do not add these packages to individual projectpackage.jsonfiles. Instead, add the config files toignoredFilesin the lint rule configuration:eslint.config.mjs '@nx/dependency-checks': ['error',{ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}','{projectRoot}/vite.config.{js,ts,mjs,mts}','{projectRoot}/vitest.config.{js,ts,mjs,mts}','{projectRoot}/jest.config.{js,ts,cjs,cts,mjs,mts}',],},],
Once the above passes, run end-to-end tests:
nx run-many -t e2eFix any e2e failures before considering the migration complete. Common e2e issues include:
- Vite/webpack dev server resolution failures: Confirm
nxViteTsPathswas removed and workspace symlinks are in place (node_modules/@myorg/libshould be a symlink to the library directory). - Cypress/Playwright projects missing tsconfig changes: E2e projects need the same tsconfig restructuring as other projects. Their solution files should extend
tsconfig.base.jsondirectly.