Vite+ 1.0 hands-on: migrating a real Vite + ESLint + Prettier + Vitest project, and what to check afterwards
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 preview | vite, vite build, vite preview |
vp test | vitest |
vp lint, vp fmt | ESLint, Prettier |
vp check | format check + lint + tsc --noEmit in one command |
vp pack | tsup / tsdown for libraries |
vp run | npm run with caching and dependency ordering, like Turborepo |
vp env | nvm, fnm, Volta |
Installing the global CLI
curl -fsSL https://vite.plus | bashThe 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" }
}viteis now an alias for Vite+'s own build of Vite, and theoverridesentry forces every package that depends onvite(such as@vitejs/plugin-react) to use it too.devEngines.packageManagerpins the npm version that ran the migration.- Test imports are rewritten:
import { expect, test } from 'vitest'becamefrom '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
globalspackage stayed indevDependencies, 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) | Before | After |
|---|---|---|
npm run lint | 1,384 ms (ESLint) | 694 ms (Oxlint, type-aware) |
npm run format:check | 326 ms (Prettier) | 494 ms (Oxfmt) |
npm run test | 1,268 ms | 1,209 ms |
npm run build | 1,303 ms | 1,358 ms |
node_modules | 244 packages, 172 MB | 133 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 filesWith 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 CPUs | Run 1 | Run 2 | Run 3 |
|---|---|---|---|
| ESLint 9.39 + typescript-eslint | 14,570 ms | 14,350 ms | 13,189 ms |
vp lint (Oxlint 1.85) | 1,191 ms | 1,200 ms | 1,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 restoreddist/from the cache. - Editing
src/App.tsxgavecache miss: 'src/App.tsx' modified, executing. Runningtouchon a file didn't cause a miss, so the cache compares file contents, not timestamps. - Editing
README.mdre-ranvp check, because the formatter reads it, but not the build. tsc -bis never cached. With the originaltsc -b && vp buildcommand, every run reportedNot cached: read and wrote 'node_modules/.tmp/tsconfig.app.tsbuildinfo'. A command that rewrites its own input can't be cached safely. Sincevp checkalready type-checks, droptsc -bfrom 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://localhostThe 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.comSo 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 directoryThe 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:
| Image | Base | libatomic.so.1 |
|---|---|---|
node:22-slim, node:24-slim | Debian 12 | missing |
node:24-trixie-slim | Debian 13 | missing |
node:24 (full) | Debian 12 | present |
node:26-slim, node:26-bookworm-slim, node:26 | Debian 13 / 12 | present |
node:24-alpine, node:26-alpine | Alpine 3.24 | missing (musl builds of Node don't need it) |
debian:bookworm-slim, debian:trixie-slim, ubuntu:24.04 | missing |
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.0Inside 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 83msUse 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:
- Run
vp check. If it flagstriple-slash-referenceinvite.config.ts, delete the/// <reference types="vite-plus" />line. - Compare the number of lint errors with ESLint's. If
no-undefexplodes, set it to'off'for TypeScript files. - Read the skipped-rules warnings, especially
react/only-export-componentsoptions. - Remove
clearMocks: falseonce your tests don't need it. - Remove dependencies the migration left behind, like
globals. - For cached
vp runtasks, list every environment variable the build reads incache.env, and keep commands liketsc -bout of them. - If
vp env(or your runtime stage) uses Node 25+ on a Node 22/24 slim image or plain Debian/Ubuntu, installlibatomic1.
Sources & further reading
- Announcing Vite+ 1.0
- Vite+ getting started and migration guide
- Vite+: moving to Vitest 5
- Vite+: vp run, task caching and run config reference
- Vite+: vp env and Docker guide
- Oxlint documentation
- docker-node: the node:26-bookworm-slim Dockerfile (installs
libatomic1) - actions/runner #4640: install libatomic so Node.js 25+ works
- typescript-eslint FAQ: why no-undef should be off for TypeScript