1. A Vite project
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
Code: the course’s application in code/react-vite, its vite.config.ts, and the scripts that capture this lesson’s outputs: scripts/dev-start.mjs, scripts/dev-module.mjs and scripts/hmr.mjs.
What Vite is, for a .NET or Java developer
Section titled “What Vite is, for a .NET or Java developer”A .NET web front end starts with dotnet new blazorwasm, runs with dotnet watch, and ships with dotnet publish; MSBuild compiles everything before anything runs. A React project starts with create-vite, runs with vite, and ships with vite build, and in between, Vite plays two roles that .NET gives to one tool:
| .NET (Blazor) | Java (Maven) | Vite | |
|---|---|---|---|
| Create a project | dotnet new blazorwasm |
mvn archetype:generate |
npm create vite, which runs create-vite |
| Project files | .csproj |
pom.xml |
package.json, vite.config.ts, tsconfig.json |
| Development loop | dotnet watch: rebuild, then hot reload |
rebuild, restart | a dev server that compiles each file when the browser asks for it |
| Type checking | the compiler, part of every build | javac, part of every build |
tsc, a separate step that Vite doesn’t run |
| Production build | dotnet publish |
mvn package |
vite build: static HTML, JavaScript and CSS files |
During development, Vite is a server: the browser loads index.html, then asks for each module, and Vite compiles the TypeScript and JSX of each file at that moment, with Oxc, a compiler written in Rust. For production, Vite is a bundler: Rolldown, also in Rust, bundles and minifies the application into a few files. Vite 8, released in March 2026, replaced the two tools that earlier versions used for those jobs, esbuild and Rollup, with Rolldown and Oxc.
flowchart LR subgraph dev["npm run dev"] browser["browser"] -- "GET /src/App.tsx" --> server["Vite dev server"] server -- "Oxc: types removed,<br/>JSX compiled" --> browser deps["node_modules<br/>pre-bundled once"] --> server end subgraph build["npm run build"] tsc["tsc -b<br/>checks the types"] --> rolldown["vite build<br/>Rolldown bundles and minifies"] rolldown --> dist["dist/index.html<br/>dist/assets/*.js, *.css"] endThe course uses the versions current on 15 September 2026: Vite 8.3.0 with @vitejs/plugin-react 6.1.1, React 19.3.0, and TypeScript 7.0.2, the version of the TypeScript course. Vite 8 requires Node.js 20.19 or 22.12 and later; the course runs on Node.js 24.21.0, like the JavaScript and TypeScript courses.
Creating the project
Section titled “Creating the project”create-vite copies a template into a new folder. npm create vite is the short form that the documentation shows: npm turns npm create vite into running the package create-vite. The commands below pin its version and answer its questions with options, so that they give the same result every time.
npx create-vite@9.2.1 guitar-app --template react-ts --no-interactiveSet-Location guitar-appnpm installnpm run devnpx create-vite@9.2.1 guitar-app --template react-ts --no-interactivecd guitar-appnpm installnpm run devnpx create-vite@9.2.1 guitar-app --template react-ts --no-interactivecd guitar-appnpm installnpm run devWithout --no-interactive, in a terminal, create-vite asks for the name, the framework and the variant, and offers to install and start the project. The course’s check.sh runs the same command in its out folder:
│◇ Scaffolding project in out/guitar-app...│└ Done. Now run:
cd guitar-app npm install npm run devcreate-vite --help lists the other templates, among them react-compiler-ts, the same project with the React Compiler that lesson 10 covers, and an --eslint option that replaces oxlint with ESLint.
What the template contains
Section titled “What the template contains”.gitignore.oxlintrc.jsonREADME.mdindex.htmlpackage.jsonpublic/favicon.svgpublic/icons.svgsrc/App.csssrc/App.tsxsrc/assets/hero.pngsrc/assets/react.svgsrc/assets/vite.svgsrc/index.csssrc/main.tsxtsconfig.app.jsontsconfig.jsontsconfig.node.jsonvite.config.ts| File | Role | Closest thing in .NET |
|---|---|---|
index.html |
the entry point: Vite reads it, and follows its <script type="module"> |
wwwroot/index.html in Blazor WebAssembly |
src/main.tsx |
mounts the root component into the root element |
Program.cs |
src/App.tsx |
the root component | App.razor |
vite.config.ts |
Vite’s configuration, with the React plugin | the build part of the .csproj |
tsconfig.json |
two project references, one for the browser code and one for vite.config.ts |
a solution with two projects |
tsconfig.app.json, tsconfig.node.json |
the compiler options of each project | two .csproj files |
package.json |
dependencies and scripts | package references and build targets |
.oxlintrc.json |
the lint rules | .editorconfig and analyzers |
public/ |
files copied as they are, with their names | wwwroot static files |
index.html is at the root and not in public, because for Vite it is source code: the guide calls it the entry point of the module graph. Its last line loads /src/main.tsx, and main.tsx renders the application:
import { StrictMode } from 'react';import { createRoot } from 'react-dom/client';import './index.css';import App from './App.tsx';
createRoot(document.getElementById('root')!).render( <StrictMode> <App /> </StrictMode>,);createRoot takes a DOM element, and render displays a component in it; from then on React owns everything inside that element. StrictMode adds checks in development only, which lesson 3 shows. The ! is TypeScript’s non-null assertion (TypeScript lesson 2): getElementById returns HTMLElement | null, and the template knows that index.html has the element.
package.json
Section titled “package.json”{ "name": "guitar-app", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", "preview": "vite preview" }, "dependencies": { "react": "^19.2.8", "react-dom": "^19.2.8" }, "devDependencies": { "@types/node": "^24.13.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", "oxlint": "^1.81.0", "typescript": "~6.0.2", "vite": "^8.3.0" }}react is the library that knows components and state, and react-dom the part that updates the browser’s DOM; React Native replaces the second with another renderer. The @types packages hold the type declarations. The template asks for React ^19.2.8, so npm install gets 19.3.0 today, and TypeScript ~6.0.2, which allows 6.0.x only. The course’s own package.json pins every version exactly, adds Vitest, Testing Library and jsdom for the tests, and uses TypeScript 7.0.2, which checks the same two projects.
The build script runs tsc -b first, the -b of project references, which checks both projects, and vite build only if tsc succeeds. That order matters, and the rest of the lesson shows why.
The dev server
Section titled “The dev server”npm run dev runs vite:
VITE v8.3.0 ready in N ms
➜ Local: http://localhost:5199/ ➜ Network: use --host to exposeVite’s default port is 5173; the course’s scripts use 5199 with --strictPort, which stops instead of picking the next free port. In a terminal, Vite also prints press h + enter to show help, the shortcuts to open the browser, restart or quit. The server starts in a fraction of a second whatever the size of the application, because it compiles nothing yet: it waits for the browser.
scripts/dev-module.mjs starts the same server with Vite’s JavaScript API, requests a URL as the browser would, and prints the answer. The page first:
GET / -> 200 text/html<!doctype html><html lang="en"> <head> <script type="module">import { injectIntoGlobalHook } from "/@react-refresh";injectIntoGlobalHook(window);window.$RefreshReg$ = () => {};window.$RefreshSig$ = () => (type) => type;</script>
<script type="module" src="/@vite/client"></script>
<meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>React (Vite) course</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body></html>Vite added two scripts to index.html: /@vite/client, which connects to the server to receive updates, and the preamble of React Refresh, from the React plugin. Then the browser asks for /src/main.tsx:
GET /src/main.tsx -> 200 text/javascriptconst StrictMode = __vite__cjsImport0_react["StrictMode"];const createRoot = __vite__cjsImport1_reactDom_client["createRoot"];const _jsxDEV = __vite__cjsImport4_react_jsxDevRuntime["jsxDEV"];import __vite__cjsImport0_react from "/node_modules/.vite/deps/react.js?v=HASH";import __vite__cjsImport1_reactDom_client from "/node_modules/.vite/deps/react-dom_client.js?v=HASH";import "/src/index.css";import App from "/src/App.tsx";var _jsxFileName = "src/main.tsx";import __vite__cjsImport4_react_jsxDevRuntime from "/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=HASH";createRoot(document.getElementById("root")).render(/* @__PURE__ */ _jsxDEV(StrictMode, { children: /* @__PURE__ */ _jsxDEV(App, {}, void 0, false, { fileName: _jsxFileName, lineNumber: 8, columnNumber: 5}, this) }, void 0, false, { fileName: _jsxFileName, lineNumber: 7, columnNumber: 3}, this));
//# sourceMappingURL=data:application/json;base64,…That file is what the browser runs, and each change from the source has a reason:
- The types are gone, including the
!aftergetElementById. As in TypeScript lesson 1, removing types checks nothing. - JSX became function calls:
<StrictMode><App /></StrictMode>is now_jsxDEV(StrictMode, { children: _jsxDEV(App, {}) }), with the file name and line of each element so that React’s warnings can point to the source. Lesson 2 opens that transformation. - The imports became URLs, because the browser loads modules by URL and knows nothing of
node_modules.reactis served from/node_modules/.vite/deps/react.js: Vite pre-bundled each dependency into one file once, converted React’s CommonJS package to an ES module, hence the__vite__cjsImportnames, and added a hash for the browser’s cache. - CSS is imported like a module:
import "/src/index.css"returns a small script that inserts the styles into the page. - A source map is inline, so the browser’s debugger shows
main.tsxas you wrote it. The course’s outputs shorten it to….
A .NET developer can compare it with a Razor file compiled on demand, one file per request, with the difference that the result goes to the browser and not to the server’s memory. That is why startup doesn’t depend on the size of the application: a page that isn’t open is never compiled.
Hot module replacement and Fast Refresh
Section titled “Hot module replacement and Fast Refresh”Edit a component and save: the page changes without reloading, and the component keeps its state. Two mechanisms work together. Vite’s HMR API sends a message to the page when a file changes, and React Refresh, the part of the React plugin also called Fast Refresh, replaces the component’s function while keeping the state that React holds for it. The end of what the server sends for App.tsx shows the plugin’s code:
// the end of GET /src/App.tsx, from expected/l01_dev_app.txt_s(App, "oDgYfYHkD9Wkv4hrAPCkI/ev3YU=");_c = App;var _c;$RefreshReg$(_c, "App");import * as RefreshRuntime from "/@react-refresh";const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;import * as __vite_react_currentExports from "/src/App.tsx";if (import.meta.hot && !inWebWorker) { if (!window.$RefreshReg$) { throw new Error( "@vitejs/plugin-react can't detect preamble. Something is wrong." ); }
const currentExports = __vite_react_currentExports; queueMicrotask(() => { RefreshRuntime.registerExportsForReactRefresh("src/App.tsx", currentExports); import.meta.hot.accept((nextExports) => { if (!nextExports) return; const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate("src/App.tsx", currentExports, nextExports); if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage); }); });}$RefreshReg$ registers the component App under its file and name, and _s(App, "…") records a signature of the hooks it calls, here one useState. import.meta.hot.accept tells Vite that this module can replace itself: when the file changes, the page imports the new version, and React Refresh swaps the function. If the signature changed, because a hook was added or removed, the old state no longer matches, and React mounts the component again.
scripts/hmr.mjs connects to the server’s WebSocket with the vite-hmr protocol, as /@vite/client does, requests the page’s modules, edits the title in a copy of App.tsx, and prints what the server sends:
{ "type": "connected"}{ "type": "update", "updates": [ { "type": "js-update", "timestamp": TIMESTAMP, "path": "/src/App.tsx", "acceptedPath": "/src/App.tsx", "explicitImportRequired": false, "isWithinCircularImport": false } ]}One message, for one module: the page will import /src/App.tsx again, and nothing else. I checked the rest by hand in a browser, on Windows: after three clicks on the counter and two on the capo, editing the title in App.tsx changed the heading, the counter still said 3 and the capo 2, and a variable set on window before the edit was still there, so the page hadn’t reloaded. That is dotnet watch’s hot reload, with the difference that the state survives in a component whose code changed.
Fast Refresh has one condition, which the template’s lint configuration enforces with react/only-export-components: a file that exports components should export only components. A file that also exports a function or a constant can’t be replaced safely, and a change to it reloads the page. The course keeps its helpers, such as transpose in lesson 3, in .ts files of their own.
A type error ships
Section titled “A type error ships”Vite removes types; it doesn’t check them. The course’s check.sh copies the application to out/l01-type-error and changes one line of App.tsx, from count + 1 to count + '1', then builds it:
> npx vite buildvite v8.3.0 building client environment for production...transforming...✓ 24 modules transformed.rendering chunks...computing gzip size...dist/index.html 0.40 kB │ gzip: 0.27 kBdist/assets/index-Cl5cuL7y.css 0.09 kB │ gzip: 0.11 kBdist/assets/index-QMb0bu8p.js 222.62 kB │ gzip: 69.73 kB
✓ built in N msThe build succeeds. I served that bundle with vite preview and clicked the button twice: it showed Count is 01, then Count is 011, because count + '1' concatenates, as JavaScript lesson 2 explains. tsc -b on the same copy:
> npx tsc -b --pretty falsesrc/App.tsx(10,53): error TS2345: Argument of type '(count: number) => string' is not assignable to parameter of type 'SetStateAction<number>'. Type '(count: number) => string' is not assignable to type '(prevState: number) => number'. Type 'string' is not assignable to type 'number'.tsc exits with code 1, and npm run build, which is tsc -b && vite build, stops before Vite. The Vite documentation says it outright: Vite only transpiles, type checking belongs to the editor and the build process, and it suggests tsc --noEmit --watch in a second terminal during development. A project whose build script is only vite build ships whatever tsc would have refused; the TypeScript course found such a project in GA.
Building for production
Section titled “Building for production”npm run build on the course’s application:
> npx tsc -b --pretty false> npx vite buildvite v8.3.0 building client environment for production...transforming...✓ 24 modules transformed.rendering chunks...computing gzip size...dist/index.html 0.40 kB │ gzip: 0.27 kBdist/assets/index-Cl5cuL7y.css 0.09 kB │ gzip: 0.11 kBdist/assets/index-BQ_Vbf-Q.js 222.62 kB │ gzip: 69.73 kB
✓ built in N ms> ls distassets/index-BQ_Vbf-Q.jsassets/index-Cl5cuL7y.cssindex.htmlTwenty-four modules became one JavaScript file and one CSS file, minified by Oxc. Most of the 223 kB, 70 kB compressed, is React and React DOM. The hash in each file name changes with the content: the type error above gave index-QMb0bu8p.js for a file of the same size. A server can then tell browsers to cache assets/ forever, since a new build writes new names, and only index.html has to be revalidated.
dist is a static site: any web server can serve it, including ASP.NET Core’s static files or Spring Boot’s static folder, which lesson 13 sets up. npm run preview runs vite preview, a local server for dist that the documentation says not to use as a production server. By default the bundle targets the browsers of Baseline Widely Available at a date fixed for each major version of Vite; for Vite 8, that is Chrome and Edge 111, Firefox 114 and Safari 16.4 (building for production).
In GuitarAlchemist/ga
Section titled “In GuitarAlchemist/ga”GA’s main React application, ReactComponents/ga-react-components, at commit 8cc8c5a, is a Vite project a generation older: its package.json asks for React ^18.3.1, @vitejs/plugin-react ^4.3.2 and Vite ^5.4.8, so esbuild and Rollup rather than Oxc and Rolldown. Its build script is vite build alone, with tsc -b in two separate scripts, build:with-types and typecheck.
Its vite.config.ts has 3,005 lines, and shows how far the dev server can go. The configuration itself lists six plugins, listens on port 5176 on every network interface, and proxies /api, /hubs and /graphql to the ASP.NET Core backend on port 5232, and /proxy/ollama to a local Ollama server. Four of the plugins add middleware with configureServer: one of them alone registers 27 handlers, 23 of them under /dev-data/, which serve the repository’s backlog, quality reports and agent activity as JSON. A comment explains that a Cloudflare tunnel exposes this dev server as the public demo site, so every request looks local, and a function checks the Host header to keep the control endpoints private. The dev server is a Node.js web server, and GA uses it as one; vite build writes none of that middleware into dist, and a static deployment would have none of those endpoints.
Apps/ga-client is the other React application. Its dev script prints that it is the legacy demo client, and exits with an error so that its Vite server doesn’t take the port of the main one; dev:legacy starts it anyway.
Key takeaways
Section titled “Key takeaways”- Vite is a dev server that compiles each file when the browser requests it, and a bundler, Rolldown, for production. Vite 8 uses Oxc and Rolldown where earlier versions used esbuild and Rollup.
index.htmlis the entry point;main.tsxmounts the root component withcreateRoot.- The dev server removes types, compiles JSX to function calls, turns imports into URLs and serves pre-bundled dependencies.
- HMR sends one update per changed module, and Fast Refresh swaps a component while keeping its state, as long as its file exports only components.
- Vite never checks types: keep
tsc -bbeforevite build, as the template’sbuildscript does. vite buildwrites a static site with content-hashed file names, for any web server.
Exercises
Section titled “Exercises”- Scaffold the template with
create-vite9.2.1, and compare itspackage.jsonandtsconfig.app.jsonwith the course’spackage.jsonandtsconfig.app.json. What did the course change, and why?
Solution
The differences, from the template’s files above and the course’s:
| Template | Course | Why | |
|---|---|---|---|
| Versions | ranges: ^19.2.8, ~6.0.2 |
exact: 19.3.0, 7.0.2 |
the outputs pasted in the lessons must not change when a new version is published |
| TypeScript | 6.0 | 7.0.2 | the TypeScript course’s version, and tsc -b gives the same result on this project |
| Tests | none | vitest, jsdom, @testing-library/react, @testing-library/dom, @testing-library/user-event |
every component of lessons 2 to 4 has a test |
strict |
not written: it is the default since TypeScript 6.0 | written out | code copied into an older project still fails loudly, as TypeScript lesson 1 argues |
allowArbitraryExtensions |
on, for imports such as .png with a declaration file |
absent | the course imports no assets |
vite.config.ts |
the React plugin | adds a test section for Vitest |
the tests run in jsdom, a DOM written in JavaScript |
create-vite |
— | a development dependency | check.sh runs the scaffold of this lesson with the pinned version |
The course also removed the template’s demo page, its images and CSS, and kept index.html, main.tsx, the two-project tsconfig.json and the lint configuration as they were.
- In a copy of the application, make
npm run buildfail on a type error, then makevite buildalone ship it. Which exit codes do you get, and what does the button show in the built page?
Solution
That is the experiment of A type error ships, which check.sh runs on every CI run: vite build exits with code 0 and writes dist, tsc -b exits with code 1, and npm run build stops after tsc, so dist isn’t written. In the page built by vite build alone, the first click shows Count is 01: the initial 0 is a number, 0 + '1' is the string '01', and the next click gives '011'.
- This site is served under
/learn. Build the application for a site served under/learn/react-vite/, and find what changes indist/index.html.
Solution
The base option, in vite.config.ts or on the command line:
> npx vite build --base /learn/react-vite/ --outDir out/l01-basevite v8.3.0 building client environment for production...transforming...✓ 24 modules transformed.rendering chunks...computing gzip size...out/l01-base/index.html 0.43 kB │ gzip: 0.28 kBout/l01-base/assets/index-Cl5cuL7y.css 0.09 kB │ gzip: 0.11 kBout/l01-base/assets/index-BQ_Vbf-Q.js 222.62 kB │ gzip: 69.73 kB
✓ built in N ms<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>React (Vite) course</title> <script type="module" crossorigin src="/learn/react-vite/assets/index-BQ_Vbf-Q.js"></script> <link rel="stylesheet" crossorigin href="/learn/react-vite/assets/index-Cl5cuL7y.css"> </head> <body> <div id="root"></div> </body></html>The script and the stylesheet now start with /learn/react-vite/, and the JavaScript file has the same hash as before, so its content didn’t change. Vite also moved the <script> into the <head> and removed the one that loaded /src/main.tsx. Without base, a page served at /learn/react-vite/ would ask for /assets/index-….js at the root of the domain, and get a 404. In check.sh, the command runs with MSYS_NO_PATHCONV=1: Git Bash on Windows turns an argument that starts with / into a Windows path, and the first version of the course’s output said /Program Files/Git/learn/react-vite/assets/….
Sources
Section titled “Sources”- Vite — Getting started, Why Vite, Features: TypeScript, Dependency pre-bundling, HMR API, Building for production, CLI, Announcing Vite 8
- React — Build a React app from scratch,
createRoot,StrictMode, React 19.3 @vitejs/plugin-react, React Refresh, Oxc, Rolldown- Microsoft — .NET hot reload with dotnet watch