Home → Vite+ 1.0 hands-on: migrating a real Vite + ESLint + Prettier + Vitest project, and what to check afterwards

Vite+ 1.0 hands-on: migrating a real Vite + ESLint + Prettier + Vitest project, and what to check afterwards

By · Node.js & JavaScript developer
Published October 2, 2026

Vite+ reached 1.0 on September 28, 2026. It's an MIT-licensed toolchain from VoidZero that puts Vite, Vitest, Rolldown, the Oxlint linter, the Oxfmt formatter and tsdown behind a single CLI, vp, with one config file. It can also manage your Node.js version and package manager. The pitch is that one dependency replaces a whole stack: vite, vitest, eslint and its plugins, prettier, tsup and nvm.

This post takes a typical existing project and moves it to Vite+ with vp migrate, checking each step. The project is the create-vite React + TypeScript template, with the ESLint flat config that template shipped until recently, Prettier, and Vitest 4.1 with a Testing Library component test. Everything ran in Docker on node:24-slim (Node 24.21, npm 11.19) with a 4-core VM, using vite-plus 1.0.0.

What's inside Vite+ 1.0

$ vp toolchain
[email protected]
├── depends on @voidzero-dev/[email protected]
│   ├── bundles [email protected]
│   ├── bundles [email protected]
│   └── bundles [email protected]
├── depends on [email protected]
├── depends on [email protected]
├── depends on [email protected]
├── depends on [email protected]
└── compiles vite-task (built 2026-09-28T02:31:26Z, …)

(Trimmed slightly: the full output also lists the Oxc versions that Rolldown compiles.) Note that Vite+ ships Vitest 5, so migrating a Vitest 4 project also means going through the Vitest 5 breaking changes. Vite+ handles some of them for you, as shown below. For the rest, see Upgrading to Vitest 5: every breaking change tested. The npm package requires Node ^22.18.0 || ^24.11.0 || >=26.0.0.

The commands map onto the tools you already know:

Vite+Replaces
vp dev, vp build, vp previewvite, vite build, vite preview
vp testvitest
vp lint, vp fmtESLint, Prettier
vp checkformat check + lint + tsc --noEmit in one command
vp packtsup / tsdown for libraries
vp runnpm run with caching and dependency ordering, like Turborepo
vp envnvm, fnm, Volta

Installing the global CLI

curl -fsSL https://vite.plus | bash

The installer puts vp in ~/.local/share/vite-plus/bin and adds a line to ~/.bashrc that loads its environment. It also creates shims for node, npm, npx, pnpm, yarn and bun, because by default Vite+ takes over choosing your Node.js and package-manager versions. The install used 355 MB before downloading any Node versions.

If you'd rather keep your current Node setup, install with VP_NODE_MANAGER=no, or run vp env off afterwards. That's "system-first" mode, where your existing node and npm stay first on the PATH:

$ vp env doctor
Configuration
  ✓ Node.js           system-first mode
    System Node.js    /usr/local/bin/node
  ✓ npm               system-first mode
  ✓ pnpm              managed mode
…

Your teammates and CI don't need the global CLI. After migration, vite-plus is an ordinary dev dependency. In a fresh node:24-slim container with no vp installed, npm ci (136 packages, 2 s) followed by npm run lint, format:check, test and build all passed, because the scripts call the local vp binary from node_modules/.bin.

Before you migrate: Vite 8 and Vitest 4

vp migrate expects a project that is already on Vite 8 and at least Vitest 4. On a copy of the project downgraded to Vite 7 and Vitest 3, it stopped without touching anything and exited with code 1:

Vitest v5: 1 review item (1 block dependency updates)

package.json
  1:1 BLOCK [source-version] Upgrade the original project to Vitest 4 before running this migration.
    Docs: https://viteplus.dev/guide/vitest-v5#before-you-migrate
Resolve the blocking Vitest v5 findings, then re-run `vp migrate`. No project files were changed.

Commit everything first, so the migration is a single diff you can review or throw away.

Running vp migrate

$ vp migrate --no-interactive --no-agent --no-editor --no-hooks
Prettier configuration detected. Auto-migrating to Oxfmt...
Formatting code...
Code formatted
◇ Migrated . to Vite+ 1.0.0
• Node 24.21.0  npm 11.19.0
✓ Dependencies installed in 10s
• 4 config updates applied, 2 files had imports rewritten
• ESLint rules migrated to Oxlint
• Prettier migrated to Oxfmt
• Inline Vite plugins wrapped with lazyPlugins for check/lint/fmt
! Warnings:
  - Skipped 4 rules:
    - 4 Unsupported
      - no-dupe-args: Superseded by strict mode.
      - no-octal: Superseded by strict mode.
      - react-hooks/config: Oxlint uses fixed, valid React Compiler options; add this rule when compiler options become configurable.
      - react-hooks/gating: Oxlint does not expose React Compiler gating options; add this rule when compiler options become configurable.
  - The bundled Oxlint does not support react/only-export-components.allowCompoundComponents. Removed this option from the migrated config; compound component exports may now report lint errors.

The --no-agent, --no-editor and --no-hooks flags skip the optional steps that write AI agent instructions, editor settings and Git hooks. The migration deleted eslint.config.js and .prettierrc.json and changed five files. The interesting parts of the diff:

   "scripts": {
-    "dev": "vite",
-    "build": "tsc -b && vite build",
-    "lint": "eslint .",
-    "format": "prettier --write .",
-    "format:check": "prettier --check .",
-    "test": "vitest run"
+    "dev": "vp dev",
+    "build": "tsc -b && vp build",
+    "lint": "vp lint .",
+    "format": "vp fmt .",
+    "format:check": "vp fmt --check .",
+    "test": "vp test run"
   },
   "devDependencies": {
-    "@eslint/js": "^9.39.5",
-    "eslint": "^9.39.5",
-    "eslint-plugin-react-hooks": "^7.1.1",
-    "eslint-plugin-react-refresh": "^0.5.7",
-    "prettier": "^3.9.9",
-    "typescript-eslint": "^8.71.0",
-    "vite": "^8.3.0",
-    "vitest": "^4.1.11"
+    "vite": "npm:@voidzero-dev/[email protected]",
+    "vite-plus": "1.0.0"
+  },
+  "overrides": {
+    "vite": "npm:@voidzero-dev/[email protected]"
+  },
+  "devEngines": {
+    "packageManager": { "name": "npm", "version": "11.19.0", "onFail": "download" }
   }
  • vite is now an alias for Vite+'s own build of Vite, and the overrides entry forces every package that depends on vite (such as @vitejs/plugin-react) to use it too.
  • devEngines.packageManager pins the npm version that ran the migration.
  • Test imports are rewritten: import { expect, test } from 'vitest' became from 'vite-plus/test', and a lint rule (vite-plus/prefer-vite-plus-imports) enforces it from now on.
  • .vitest/ was added to .gitignore, the folder Vitest 5 now writes its reports to.
  • The globals package stayed in devDependencies, although nothing uses it any more. You can remove it.

vite.config.ts grew from 11 lines to about 150. The ESLint presets (js.configs.recommended, tseslint.configs.recommended and the React Hooks and React Refresh configs) were expanded into an explicit list of about 100 Oxlint rules. The Prettier options moved into fmt. Two other changes are worth reading:

/// <reference types="vite-plus" />
import { defineConfig, lazyPlugins } from 'vite-plus'
import react from '@vitejs/plugin-react'

export default defineConfig({
  lint: {
    plugins: ['oxc', 'typescript', 'unicorn'],
    // …about 100 rules expanded from the ESLint presets…
    options: {
      typeAware: true,
      typeCheck: true,
    },
  },
  fmt: {
    semi: false,
    singleQuote: true,
    printWidth: 80,
    sortPackageJson: false,
    ignorePatterns: [],
  },
  plugins: lazyPlugins(() => [react()]),
  test: {
    // Vitest v4 compatibility: preserve mock call history.
    // Remove after tests no longer rely on calls from setup or earlier tests.
    clearMocks: false,
    environment: 'jsdom',
  },
})

typeCheck: true means vp check now also runs the TypeScript compiler, and typeAware: true turns on lint rules that need type information. clearMocks: false keeps Vitest 4's behaviour, so tests that check mock calls made outside the test keep passing on Vitest 5. The comment tells you to remove it once your tests don't depend on it. lazyPlugins() delays creating the Vite plugins, so that vp lint and vp fmt don't have to load them.

First check: the migration's own output fails lint

$ vp check
pass: All 12 files are correctly formatted (233ms, 4 threads)
error: Lint or type issues found
× typescript(triple-slash-reference): Do not use a triple slash reference for vite-plus, use `import` style instead.
   ╭─[vite.config.ts:1:1]
 1 │ /// <reference types="vite-plus" />
   · ───────────────────────────────────
 2 │ import { defineConfig, lazyPlugins } from 'vite-plus'
   ╰────
Found 1 error and 0 warnings in 4 files (449ms, 4 threads)

The triple-slash-reference rule (from tseslint.configs.recommended) only complains when a file references and imports the same module. The original config passed because it referenced vitest/config but imported from vite. The migration writes both for vite-plus. The import already provides the types, so delete the first line:

$ sed -i '1d' vite.config.ts
$ vp check
pass: All 12 files are correctly formatted (227ms, 4 threads)
pass: Found no warnings, lint errors, or type errors in 4 files (412ms, 4 threads)

After that, the tests passed, vp build produced byte-for-byte the same JavaScript bundle as before (index-BRDr3nmD.js, 222.52 kB), and tsc -b still passed.

What vp check catches

vp check runs the formatter check, the linter and, with typeCheck, the TypeScript compiler. It exited with code 1 on any problem and 0 when clean, so it can be your single CI gate. With a deliberate type error:

× typescript(TS2345): Argument of type 'string' is not assignable to parameter of type 'number | (() => number)'.
   ╭─[src/App.tsx:8:46]
 8 │   const [count, setCount] = useState<number>('zero')
   ·                                              ──────
Found 1 error and 0 warnings in 4 files (386ms, 4 threads)

An unused variable was reported twice, by the linter and by the compiler (eslint(no-unused-vars) and typescript(TS6133)). vp check --fix reformatted a badly formatted file in place. One thing to know: when the formatting check fails, vp check reports only that and doesn't get as far as lint and type errors, so a run with formatting problems can hide other errors until the formatting is fixed.

On this small project, the timings didn't change much:

Command (wall time incl. npm)BeforeAfter
npm run lint1,384 ms (ESLint)694 ms (Oxlint, type-aware)
npm run format:check326 ms (Prettier)494 ms (Oxfmt)
npm run test1,268 ms1,209 ms
npm run build1,303 ms1,358 ms
node_modules244 packages, 172 MB133 packages, 189 MB

Fewer packages, but more megabytes: Vite+ ships native binaries for each tool. Speed only starts to matter on larger codebases.

Linting a large codebase: 10× faster, after fixing no-undef

To measure the linter properly, both configs ran on Excalidraw's packages/ folder (618 TypeScript files, about 205,000 lines): the original ESLint config against the migrated Oxlint config, with type-aware rules switched off on both sides. Oxlint's first result was surprising:

ESLint total: 1191              Oxlint total: 12052
413  @typescript-eslint/no-explicit-any        10840  eslint(no-undef)
313  react-refresh/only-export-components        413  typescript(no-explicit-any)
157  @typescript-eslint/no-unused-vars           317  react(only-export-components)
 49  no-loss-of-precision                        136  eslint(no-unused-vars)
 40  react-hooks/refs                             49  eslint(no-loss-of-precision)

Almost all the extra errors were no-undef: 7,943 for expect, 2,081 for it, 486 for describe and so on. Excalidraw's tests use Vitest's globals: true. In ESLint, tseslint.configs.recommended turns no-undef off for TypeScript files, because the compiler already reports undefined names and does it better. The migration expanded js.configs.recommended into explicit rules, including 'no-undef': 'error', and lost that override. If your project uses Vitest globals or declares global types, you'll see the same flood. Turn it off the way typescript-eslint does:

'no-undef': 'off', // TypeScript checks this; typescript-eslint turns it off for TS files

With that one change, Oxlint reported 1,183 errors against ESLint's 1,191, close enough for the rules to be considered equivalent. Three runs each:

618 files, 205k lines, 4 CPUsRun 1Run 2Run 3
ESLint 9.39 + typescript-eslint14,570 ms14,350 ms13,189 ms
vp lint (Oxlint 1.85)1,191 ms1,200 ms1,186 ms

That's about 11 times faster, measured as wall time including npx startup. VoidZero quotes 50 to 100 times faster than ESLint. The difference depends on the machine, the number of cores and which ESLint plugins you use, but even on this small VM, a 14-second lint became a 1-second one.

vp run: task caching, and how it can replay a stale build

Tasks defined in vite.config.ts are cached by default (plain package.json scripts aren't). Vite+ watches which files each command reads and writes, and fingerprints them:

export default defineConfig({
  run: {
    tasks: {
      verify: {
        command: ['vp check', 'vp test run'],
      },
      release: {
        command: 'vp build', // vp check already type-checks
        dependsOn: ['verify'],
        cache: { env: ['VITE_API_URL'] },
      },
    },
  },
  // …
})
$ vp run release    # 1st run
vp run: 0/3 cache hit (0%).
$ vp run release    # nothing changed
vp run: 3/3 cache hit (100%), 2.33s saved.

What the cache did in further runs:

  • Deleting dist/ and running again was a cache hit that restored dist/ from the cache.
  • Editing src/App.tsx gave cache miss: 'src/App.tsx' modified, executing. Running touch on a file didn't cause a miss, so the cache compares file contents, not timestamps.
  • Editing README.md re-ran vp check, because the formatter reads it, but not the build.
  • tsc -b is never cached. With the original tsc -b && vp build command, every run reported Not cached: read and wrote 'node_modules/.tmp/tsconfig.app.tsbuildinfo'. A command that rewrites its own input can't be cached safely. Since vp check already type-checks, drop tsc -b from cached tasks, as in the config above.

The dangerous part is environment variables. Vite+ fingerprints only a few variables automatically. In these tests, changing VITE_* variables and NODE_ENV caused a miss, while FOO and MY_SECRET were ignored. Any other variable your build reads is invisible to the cache. With a bundle task (vp build) and a config that inlines a non-VITE_ variable:

define: { __API_BASE__: JSON.stringify(process.env.API_BASE ?? 'http://localhost') },
API_BASE unset            → Cache miss: 'vite.config.ts' modified | dist contains: http://localhost
API_BASE=api.example.com  → Cache hit - output replayed - 398ms saved | dist contains: http://localhost

The production build with API_BASE set was answered from the cache and still contained http://localhost. Once the variable was declared, the cache noticed it:

bundle: {
  command: 'vp build',
  cache: { env: ['API_BASE'] },
},
API_BASE=api.example.com  → Cache miss: env 'API_BASE' added | dist contains: https://api.example.com

So list every environment variable your build reads in cache.env, or leave deploy builds uncached. vp run --last-details shows the reason for every hit and miss, and vp cache clean deletes the cache (node_modules/.vite/task-cache).

vp env: per-project Node versions, and libatomic in slim images

In managed mode (vp env on), Vite+ picks the Node version per project. It looks at, in order, .node-version, devEngines.runtime in package.json, engines.node and .nvmrc. Pinning writes to devEngines:

$ vp env pin 26
✓ Pinned Node.js version to 26.10.0 (resolved from 26)
  Updated devEngines.runtime in /work/app/package.json
✓ Node.js 26.10.0 installed
/root/.local/share/vite-plus/js_runtime/node/26.10.0/bin/node: error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directory

The pin worked, but the downloaded Node 26 couldn't start. Since Node.js 25, the official Linux binaries link against libatomic.so.1, on both x64 and arm64. Node 22 and 24 don't: ldd on the node binary listed only libstdc++ and libgcc_s for 22 and 24, and libatomic.so.1 as well for 26. The official tarball for 25.9.0 failed with the same error, while 24.21.0 ran fine.

The official Docker images handle this. Their Dockerfiles install libatomic1 while unpacking Node and keep it only when the binary needs it. So every node:26 image has it, and the slim images for older Node versions don't:

ImageBaselibatomic.so.1
node:22-slim, node:24-slimDebian 12missing
node:24-trixie-slimDebian 13missing
node:24 (full)Debian 12present
node:26-slim, node:26-bookworm-slim, node:26Debian 13 / 12present
node:24-alpine, node:26-alpineAlpine 3.24missing (musl builds of Node don't need it)
debian:bookworm-slim, debian:trixie-slim, ubuntu:24.04missing

The problem appears whenever something downloads Node 25 or newer into an image that wasn't built for it: vp env here, but also nvm, a version manager in CI, or a multi-stage Dockerfile that copies Node 26 into a plain debian:bookworm-slim runtime stage, which is exactly the setup the Vite+ Docker guide suggests. The package is tiny (80 KB installed on Debian 12). Install it, and the pinned version works:

$ apt-get install -y libatomic1
$ cd /work/app && node -v
v26.10.0
$ cd /tmp && node -v
v24.21.0

Inside the project you get Node 26, and outside it the system Node 24. Each managed Node version takes 200–230 MB under ~/.local/share/vite-plus/js_runtime.

The official Docker image

For CI and Docker builds, VoidZero publishes ghcr.io/voidzero-dev/vite-plus (215 MB, runs as a non-root vp user). It reads the project's pinned Node version by itself:

$ docker run --rm -v "$PWD":/src:ro ghcr.io/voidzero-dev/vite-plus bash -c \
    'cp -r /src ~/app && cd ~/app && vp install && echo "node: $(node -v)" && vp check && vp build'
…
node: v26.10.0
pass: Found no warnings, lint errors, or type errors in 4 files (322ms, 4 threads)
✓ built in 83ms

Use it as the build stage only. The Vite+ docs recommend copying the build output or the Node binary into a smaller runtime image. For general Node.js image choices, see Node.js Docker images: full vs slim vs alpine vs distroless.

Should you migrate?

For a Vite app already on Vite 8, the migration is mostly mechanical, and the end result is simpler: two devDependencies entries instead of eight, one config file, one vp check for CI, and a linter that's an order of magnitude faster on a real codebase. It doesn't lock your team in either, since everything still runs through plain npm run.

Review the diff like any other large change, though. After running vp migrate:

  1. Run vp check. If it flags triple-slash-reference in vite.config.ts, delete the /// <reference types="vite-plus" /> line.
  2. Compare the number of lint errors with ESLint's. If no-undef explodes, set it to 'off' for TypeScript files.
  3. Read the skipped-rules warnings, especially react/only-export-components options.
  4. Remove clearMocks: false once your tests don't need it.
  5. Remove dependencies the migration left behind, like globals.
  6. For cached vp run tasks, list every environment variable the build reads in cache.env, and keep commands like tsc -b out of them.
  7. If vp env (or your runtime stage) uses Node 25+ on a Node 22/24 slim image or plain Debian/Ubuntu, install libatomic1.

Sources & further reading

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.