Skip to main content

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​

  • .env lives in the repo root, not inside any individual workspace.
  • import.meta.env is Vite's own convention — it's not the same as Node's process.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 own vitest.config.ts, each one resolves .env relative to itself by default. To point a workspace at the root .env instead, its vitest.config.ts needs envDir: '../':
// 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 use import.meta.env at all — process.env + dotenv is the right tool there instead, since import.meta.env doesn't exist outside Vite's transform.

Where web/ Fits in the Monorepo​

  • web/ is already wired as the third npm workspace, alongside extension/ and shared/, in the root package.json.
  • shared/ is the actual logic layer (src/api/firebase/*, src/api/dictionaryapi/*, src/types, src/firebaseConfig) meant to be consumed by both web/ and extension/.
  • To actually import @vocabpal/shared and call its services from either sibling, it has to be listed as a dependency in that sibling's own package.json — npm workspaces don't wire that up automatically just because the folders are siblings.

Done:

  • Declared @vocabpal/shared as a dependency in both web/package.json and extension/package.json
  • Ran npm install at 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 + paths in tsconfig.json only 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.alias in vitest.config.ts is 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:

ToolAlias config
Vite / Rollupresolve.alias
Webpackresolve.alias in webpack.config.js
JestmoduleNameMapper in jest.config.js
Node.js (native)the "imports" field in package.json
Next.jsreads 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 needs a.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​

  • .env lives at the repo root; import.meta.env only works inside Vite's own transform (dev server or Vitest) — never in plain Node.
  • Each workspace's vitest.config.ts resolves .env relative to itself by default — use envDir to point it at the root instead.
  • Being listed in workspaces in the root package.json does not automatically let one workspace import another — the dependency still has to be declared explicitly in that workspace's own package.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's defineConfig is a superset of Vite's own defineConfig — it's a normal Vite config file with one extra test key layered on top.
  • Native ES Modules (ESM) let the browser resolve import/export chains 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 build calls it internally. No separate Rollup install or config needed for a standard app.