Vite and the Monorepo
Ticket: SCRUM-292 | Status: Done ✅
What This Task Was About
Before building out web/, this ticket was about understanding how Vite's dev server and build process differ from Create React App, so I could navigate and configure the new web/ package confidently — specifically where Vite expects env vars, and where web/ fits alongside shared/ and extension/ in the existing monorepo.
How Vite Reads Environment Variables
.envlives in the repo root, not inside any individual workspace.import.meta.envis Vite's own convention — it's not the same as Node'sprocess.env. It only works for code that Vite itself processes: browser app code, or anything run under Vitest (since Vitest runs on top of Vite).- Because each workspace (
shared/,web/,extension/) has its ownvitest.config.ts, each one resolves.envrelative to itself by default. To point a workspace at the root.envinstead, itsvitest.config.tsneedsenvDir: '../':
// shared/vitest.config.ts
export default defineConfig({
envDir: '../', // reach the root .env instead of looking inside shared/
// ...
});
- A plain Node script (e.g. something in
scripts/) can't useimport.meta.envat all —process.env+dotenvis the right tool there instead, sinceimport.meta.envdoesn't exist outside Vite's transform.
Where web/ Fits in the Monorepo
web/is already wired as the third npm workspace, alongsideextension/andshared/, in the rootpackage.json.shared/is the actual logic layer (src/api/firebase/*,src/api/dictionaryapi/*,src/types,src/firebaseConfig) meant to be consumed by bothweb/andextension/.- To actually import
@vocabpal/sharedand call its services from either sibling, it has to be listed as a dependency in that sibling's ownpackage.json— npm workspaces don't wire that up automatically just because the folders are siblings.
Done:
- Declared
@vocabpal/sharedas a dependency in bothweb/package.jsonandextension/package.json - Ran
npm installat the root to link it
Setting Up a @/ Path Alias
Once shared/'s imports started getting deep (../../types, ../../firebaseConfig/config), I set up a @/ alias so I wouldn't have to count ../ levels by hand:
1. Alias in shared/vitest.config.ts:
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
envDir: '../',
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
globals: true,
environment: 'node',
setupFiles: ['./src/api/__tests__/setup.ts'],
},
});
2. Mirrored in shared/tsconfig.json (so TypeScript and the editor's autocomplete/go-to-definition understand @/ too):
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["vitest/globals"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}
3. Rewrote the imports across shared/src/api/firebase/*.ts and shared/src/api/dictionaryapi/*.ts:
// before
import { Word } from '../../types';
import { db } from '../../firebaseConfig/config';
// after
import { Word } from '@/types';
import { db } from '@/firebaseConfig/config';
4. Re-ran the typecheck to confirm nothing broke: cd shared && npx tsc --noEmit && npx vitest run — and this actually caught two real bugs in the process: authService.ts and categoryService.ts both had an import one ../ level too shallow ('../types' instead of '../../types'), and categoryService.ts pointed './config' at a file that didn't exist there at all.
TypeScript vs Vite: Two Separate Resolvers
The reason the alias had to be set up in two places (vitest.config.ts and tsconfig.json) is that they're two completely independent tools, and neither reads the other's config:
- TypeScript's compiler never runs the code. Its whole job is reading the code, checking types line up, and giving the editor autocomplete/red-squiggle support.
baseUrl+pathsintsconfig.jsononly affect that — zero effect on what actually executes. - Vite is the thing that actually loads and executes the file — for real, at dev-server time or test-run time.
resolve.aliasinvitest.config.tsis what tells Vite's own resolver what@means on disk.
Skip either one and you get a different failure: skip the Vite side and tests crash at runtime with "cannot resolve module"; skip the TypeScript side and tsc --noEmit fails (and the editor shows red squiggles) even though the tests run fine.
The general rule to remember: first ask "is this a type-checking tool, or a thing that actually executes/bundles my code?" — that decides which bucket it's in. Nearly every bundler/test-runner has its own version of this same alias concept, under a different name:
| Tool | Alias config |
|---|---|
| Vite / Rollup | resolve.alias |
| Webpack | resolve.alias in webpack.config.js |
| Jest | moduleNameMapper in jest.config.js |
| Node.js (native) | the "imports" field in package.json |
| Next.js | reads tsconfig.json paths automatically — no separate config needed |
Practical shortcut: because manually keeping tsconfig.json and a bundler's alias config in sync is such a common annoyance, plugins exist specifically to auto-sync them — e.g. vite-tsconfig-paths reads tsconfig.json's paths and configures Vite's resolver for you, so the alias only needs to be maintained in one place. Worth searching "<bundler> tsconfig paths plugin" next time this comes up in a new project.
One more detail: shared/vitest.config.ts imports defineConfig from 'vitest/config', not 'vite'. Vitest's version wraps Vite's own defineConfig and adds one extra key, test. Everything else in the file (resolve.alias, envDir) is standard Vite config — which is exactly why resolve.alias works in a file that "looks like" a test config. There's no separate vite.config.ts anywhere in this repo yet, so this one file is currently doing both jobs at once.
What Vite's Dev Server Does Differently From a Bundler Like Webpack
To understand this, first: what's a module? A module is just a file that shares code with other files, using import/export. Historically the browser had no idea what to do with that — <script> tags just ran whatever JS they were given, with no concept of one file importing another.
Native ES Modules (ESM): around 2017–2018, browsers added real, built-in support for import/export syntax — part of the official ECMAScript spec. "Native" means the browser itself understands import now, no tooling required. Write <script type="module" src="b.js">, and the browser reads b.js, sees import { greet } from './a.js', and goes and fetches a.js on its own — resolving the whole import chain itself, over the network, one file at a time.
Bundling is the opposite instinct: take all your separate files and their import/export chains and mash them into one (or a few) combined output file(s) ahead of time, before the browser ever sees them. A tool like webpack walks the entire chain (a.js → b.js → c.js → ...) and produces one bundle.js. Why bother, if browsers can do import natively now?
- Network cost — a real app might have hundreds of small files. Even with native ESM, that's hundreds of cascading HTTP requests (fetch
b.js, discover it needsa.js, fetch that, discover it needs something else...). One bundled file avoids that waterfall. - Extra jobs bundlers do along the way — minifying code, tree-shaking unused code, and converting things browsers can't run natively (TypeScript, JSX) into plain JS.
Vite's dev server skips the upfront bundling step entirely. It leans on native ESM — when the browser requests the page, it sees import statements and asks Vite for each file as needed, one at a time, exactly like the a.js/b.js example. Vite's only real job per file is a fast on-the-fly transform (e.g. stripping TypeScript types). Edit one file → Vite tells the browser "just re-fetch this one file" → done. No graph-wide rebuild, because there was never a bundle to rebuild in the first place.
Webpack's dev server, by contrast, has to walk the entire app's import graph and build that one bundle before it can show anything at all. Change a single file, and it has to redo a meaningful chunk of that bundling work before the browser sees the update — that's the whole speed difference.
Vite does still bundle for the production build (via Rollup) — once you're shipping to real users, fewer larger files beats hundreds of small requests. It's specifically during development that skipping the bundle step wins.
Rollup ships inside Vite
Confirmed by checking this repo directly: node_modules/vite/package.json already lists "rollup": "^4.43.0", and node_modules/rollup exists too — pulled in transitively as one of Vite's own dependencies, not something installed separately. When you run vite build, Vite calls Rollup internally to do the bundling; there's no separate rollup.config.js to write or Rollup command to run directly.
What's still missing before vite build actually works here
vite itself isn't declared as a dependency anywhere in this repo yet. The vite binary visible in node_modules/.bin right now is only there because vitest (which every workspace already depends on) pulls Vite in transitively as its own dependency. To run vite build / vite dev for real, vite (and @vitejs/plugin-react, for JSX/Fast Refresh support) needs to be added explicitly as a devDependency in web/package.json — that's part of what SCRUM-293 ("Create web folder with Vite + React + TypeScript") covers.
Key Takeaways
.envlives at the repo root;import.meta.envonly works inside Vite's own transform (dev server or Vitest) — never in plain Node.- Each workspace's
vitest.config.tsresolves.envrelative to itself by default — useenvDirto point it at the root instead. - Being listed in
workspacesin the rootpackage.jsondoes not automatically let one workspace import another — the dependency still has to be declared explicitly in that workspace's ownpackage.json. - A path alias needs to be configured twice: once for TypeScript (
tsconfig.json→baseUrl/paths, for type-checking and editor support) and once for the actual bundler/runtime (vite.config.ts/vitest.config.ts→resolve.alias, for what really executes). Every JS ecosystem tool that resolves modules keeps its own separate alias config. vitest/config'sdefineConfigis a superset of Vite's owndefineConfig— it's a normal Vite config file with one extratestkey layered on top.- Native ES Modules (ESM) let the browser resolve
import/exportchains itself, no bundler required. Bundling exists on top of that mainly to avoid a network waterfall of hundreds of small requests, plus minification/tree-shaking/TS-JSX transforms. - Vite's dev server skips bundling entirely and serves modules on demand over native ESM — that's why editing one file only means "re-fetch this one file," not a graph-wide rebuild like webpack's dev server has to do.
- Rollup ships inside Vite as a dependency —
vite buildcalls it internally. No separate Rollup install or config needed for a standard app.