Skip to content
vxvx
GitHubBlueskydev.toRSS
Blog

From Nx to vx: keep the graph, drop the daemon

One command writes native vx config from the project graph your installed Nx resolves, plugin-inferred targets included.

One command writes native vx config from the graph your installed Nx resolves. Targets that Nx plugins infer come along.

Terminal window
$ bunx @vzn/vx-migrate
from nx graph
ran bun add -d @vzn/vx @vzn/vx-lockfile @vzn/vx-history
tasks 2 clean
files libs/ui/vx.config.ts
apps/web/vx.config.ts
.vx/workspace.ts
.vx/.gitignore
lock 2 project configs → .vx/projects.lock.json
next vx run build --all
$ vx run build --all
⏺︎ 9ms success miss ui#build
⏺︎ 7ms success miss web#build
$ rm -rf apps/web/dist && vx run build --all
cache local 1✓ 1↓

Two Nx projects, Nx 24.0.1 · vx-migrate 69617eb · 2026-10-23

apps/web/vx.config.ts
import type { ProjectConfig } from '@vzn/vx/config'
export default {
tasks: {
build: {
exec: {
command: 'bun build src/index.ts --outdir dist',
},
dependsOn: ['^build'],
inputs: {
files: ['src/**/*'],
},
outputs: {
files: ['dist'],
},
cache: true,
},
},
} satisfies ProjectConfig

targetDefaults and namedInputs are already applied in the graph, so each task gets its own values. In vx a shared list is an import.

To check first, bunx @vzn/vx init --keep writes only this:

import type { WorkspaceConfig } from '@vzn/vx/config'
import { nx } from '@vzn/vx-migrate'
import { npm } from '@vzn/vx-lockfile'
import { vxHistory } from '@vzn/vx-history'
export default {
plugins: [
nx(),
npm(),
vxHistory(),
],
} satisfies WorkspaceConfig
{ "build": { "executor": "@nx/js:tsc", "options": { "main": "src/index.ts", "tsConfig": "tsconfig.lib.json" } } }

Each becomes an nx-exec line that runs the executor through Nx’s public runExecutor, so nothing breaks on day one.

An executor target is written as its nx-exec line, and you rewrite it as the real command when you want Nx gone:

build: {
exec: { command: 'tsc -b tsconfig.lib.json' },
inputs: { files: ['src/**', 'tsconfig.lib.json'] }, outputs: { files: ['dist/**', '*.tsbuildinfo'] }, cache: true,
}

These server executors come through as persistent tasks, and so does any target Nx marks continuous:

  • @nx/vite:dev-server, @nx/vite:preview-server
  • @nx/webpack:dev-server, @nx/next:server
  • @nx/storybook:storybook, @nx/web:file-server
  • @nx/js:node, @nx/js:verdaccio
  • @angular-devkit/build-angular:dev-server
Nx vx
a project’s targets tasks
dependsOn (^build, …) dependsOn, same syntax
inputs / namedInputs (resolved) inputs.files
{workspaceRoot}/file inputs.workspaceFiles
outputs outputs.files
nx affected vx run … --affected[=<base>]
nx run-many --projects vx run … --filter
parallelism: false a TODO naming --concurrency 1
nx watch vx watch
targetDefaults already applied in the graph; share them as a preset you import
Nothing changed
vx559 ms
Nx12.07 s
Cold build: CPU the runner burns
vx42.19 s
Nx5 min 31 s
The table
9,603 tasks, native config vx Nx
Nothing changed 559 ms 12.07 s
Cold build: CPU the runner burns 42.19 s 5 min 31 s

Synthetic monorepo, 1,601 projects · concurrency 10 · benchmarks

Measured: vx 0.0.0 @ ee4c63a57 on 2026-11-12, Turborepo 2.11.10 on 2026-11-12, Nx 24.0.2 on 2026-11-12, Vite Task 1.4.0 on 2026-11-12; linux x64, 4 cores (AMD EPYC 7763 64-Core Processor), 16 GB.

  • The daemon: vx has none.
  • Distributed task execution: @vzn/vx-dte (alpha) on your own CI (how), or @vzn/vx-reapi on a Bazel pool.
  • Remote cache: nxCache() speaks the self-hosted /v1/cache wire.
  • Flaky tests: from local run history.
  • Graph view: vx run build --all --dry=json or vx show.

Generators have no vx replacement. Guide: Migrate from Nx. More at vznjs.github.io/vx.

Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.