Skip to content
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

To get the performance benefits of TypeScript project references, use package manager workspaces for project linking instead of TypeScript path aliases.

Configure your package manager to use workspaces for project linking.

package.json
{
"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.

apps/my-app/package.json
{
"devDependencies": {
"@my-org/some-project": "*"
}
}

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-tsc
dist
test-output

Use root-level entries (not path-prefixed like /dist) so they match in nested project directories.

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.

tsconfig.base.json
{
"compilerOptions": {
"allowJs": false,
"allowSyntheticDefaultImports": true,
// ...
"paths": {
"@myorg/utils": ["libs/utils/src/index.ts"],
"@myorg/ui": ["libs/ui/src/index.ts"],
},
},
}

The root tsconfig.json should extend tsconfig.base.json, include no files, and list references for every project so editor tooling works correctly.

tsconfig.json
{
"extends": "./tsconfig.base.json",
"files": [], // intentionally empty
}

Install @nx/js and register @nx/js/typescript as a plugin in nx.json:

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.

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.

libs/ui/project.json
{
"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",
},
},
},
}

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.

After removing explicit build targets, confirm the plugin infers the correct targets for each project:

Terminal window
nx show project my-lib

For 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.

libs/ui/package.json
{
"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"
}
}
}

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.

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:

  1. Drop the original scope: @myorg/shared/ui becomes @shared/ui
  2. Combine scope segments with a dash: @myorg/shared/ui becomes @myorg-shared/ui
Old path aliasOption 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.

libs/ui/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"strict": true,
"forceConsistentCasingInFileNames": true,
},
"files": [],
"include": [],
"references": [
{ "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.

libs/ui/tsconfig.lib.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": ["node"],
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
}

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.

libs/ui/tsconfig.spec.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["jest", "node"],
},
"include": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.d.ts"],
}

After updating the tsconfig files, run nx sync to have Nx add the correct project references.

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.

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"]
}

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';

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:

  1. Remove nxViteTsPaths from plugins (if present). This plugin resolved TypeScript path aliases in older Nx versions. After migration, Vite built-in resolver finds local projects through node_modules symlinks. Newer Nx versions may not include this plugin.
  2. Add resolve.conditions with the same custom condition used in tsconfig.base.json (e.g. @myorg/source). This lets Vite resolve workspace libraries to source files during dev server and test runs, matching TypeScript's customConditions behavior. See Testing without building dependencies for more details on how this works.
  3. Set build.outDir to ./dist relative to the project folder.
libs/ui/vite.config.ts
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',
// ...
},
});

Run the full target suite to confirm everything works:

Terminal window
nx run-many -t typecheck,build,lint,test

Fix any issues that come up before continuing. Common problems at this stage include:

  • Missing references: Run nx sync to populate project references automatically.

  • TS2307 "Cannot find module": If the error suggests updating to node16, nodenext, or bundler, your moduleResolution is set to "node" (Node10) which does not support the exports field in package.json. Change moduleResolution to "bundler" in tsconfig.base.json.

  • TS5095 module: "commonjs" incompatible with moduleResolution: "bundler": Project-level tsconfigs (especially spec and e2e files) that set module: "commonjs" will conflict with moduleResolution: "bundler" inherited from the base config. Remove module and moduleResolution from these files — Jest and Vitest use their own transformers and don't rely on the tsconfig module setting for execution.

  • Import path errors: Ensure all import paths match the name field in each project's package.json.

  • Missing exports in package.json: Verify each library has an exports field. Non-buildable libraries should point all conditions to source (e.g. ./src/index.ts). Buildable libraries should point import/default to compiled output (e.g. ./dist/index.js) but keep types pointing to source so TypeScript resolves correctly within the monorepo.

  • @nx/dependency-checks lint errors: For buildable libraries, this lint rule requires runtime workspace dependencies to be in dependencies (not devDependencies). If the rule reports a missing dependency for a workspace library that is imported in production code, move it from devDependencies to dependencies.

  • @nx/dependency-checks false positives for dev tool packages: Buildable libraries using this rule may report missing dependencies for packages like vitest, @nx/vite, or jest after migration. This happens because the rule scans config files like vitest.config.mts and detects imports that shouldn't be declared as project-level dependencies — they belong in the root package.json. Do not add these packages to individual project package.json files. Instead, add the config files to ignoredFiles in 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:

Terminal window
nx run-many -t e2e

Fix any e2e failures before considering the migration complete. Common e2e issues include:

  • Vite/webpack dev server resolution failures: Confirm nxViteTsPaths was removed and workspace symlinks are in place (node_modules/@myorg/lib should 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.json directly.