| name | vite-react-monorepo |
| description | Vite + React in Turborepo/pnpm monorepo methodology — workspace-aware config, path resolution, HMR with workspace packages, dev proxy, environment variables, React Router v7 data patterns, and production builds. Load when building React frontends in a monorepo. |
| user-invocable | false |
Vite + React in Turborepo/pnpm Monorepo
Methodology for building React frontends with Vite inside a Turborepo/pnpm
workspace. Covers config, path resolution, HMR, dev proxy, env vars,
React Router v7 data patterns, API client patterns, and production builds.
1. When to Use
- Setting up a new React app inside
apps/ in a Turborepo monorepo
- Configuring Vite to resolve workspace packages under
packages/
- Wiring HMR for shared component libraries across the monorepo
- Setting up dev proxy from Vite to a NestJS (or other) backend API
- Configuring TypeScript for Vite (diverges from NestJS config)
- Building React Router v7 route trees with data loaders
- Creating typed API client wrappers consuming shared types
- Optimizing production builds with code splitting
2. Vite Config
Full monorepo-aware vite.config.ts:
import { defineConfig, searchForWorkspaceRoot } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@repo/ui': path.resolve(__dirname, '../../packages/ui/src'),
'@repo/hooks': path.resolve(__dirname, '../../packages/hooks/src'),
},
},
server: {
fs: {
allow: [searchForWorkspaceRoot(process.cwd())],
},
watch: {
ignored: ['!**/node_modules/@repo/**'],
},
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
optimizeDeps: {
exclude: ['@repo/ui', '@repo/hooks', '@repo/utils'],
},
build: {
target: 'es2020',
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
},
},
},
},
})
Key settings explained
| Setting | Why |
|---|
server.fs.allow + searchForWorkspaceRoot | Vite 2.7+ defaults to strict FS. Without this, imports from packages/ fail with 403 |
optimizeDeps.exclude | Prevents pre-bundling workspace packages so they appear in the dependency graph for HMR |
server.watch.ignored negation | Vite skips node_modules/ by default. Negated glob makes it watch @repo/ packages |
resolve.alias to src/ | Points directly to TypeScript source for true HMR (no build step needed) |
3. Path Resolution
TypeScript paths are compiler-only — they tell tsc how to resolve
types. Vite does NOT read tsconfig paths at runtime. Both must be configured.
Three approaches
| Approach | Sync effort | HMR quality | Best for |
|---|
Manual alias in both tsconfig.json and vite.config.ts | High (keep in sync) | Good | Small projects with few aliases |
vite-tsconfig-paths plugin | None (auto-syncs) | Good | @/ style app-internal aliases |
Explicit resolve.alias to packages/*/src | Medium | True HMR | Workspace packages (gold standard) |
Pattern: vite-tsconfig-paths for app aliases
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [react(), tsconfigPaths()],
})
Pattern: explicit alias for workspace packages
resolve: {
alias: {
'@repo/ui': path.resolve(__dirname, '../../packages/ui/src'),
'@repo/hooks': path.resolve(__dirname, '../../packages/hooks/src'),
},
}
This is the gold standard for HMR — Vite watches the TypeScript source
directly instead of going through node_modules. Confirmed effective in
production monorepos (Stack Overflow Sep 2024, DEV Nov 2025).
Dependency direction (prevent circulars)
utils -> types -> api-client -> hooks -> ui -> apps
4. TypeScript Config
CRITICAL: Vite and NestJS require different TypeScript configurations.
They CANNOT share a base tsconfig for module/moduleResolution.
| Setting | Vite (frontend) | NestJS (backend) |
|---|
module | ESNext | CommonJS |
moduleResolution | Bundler | node / node16 |
jsx | react-jsx | N/A |
isolatedModules | true (required by Vite) | optional |
noEmit | true (Vite handles emit) | false (tsc compiles) |
types | ["vite/client"] | ["node"] |
Recommended tsconfig structure
packages/typescript-config/
tsconfig.base.json # Shared strict settings only
tsconfig.vite.json # Extends base, adds ESNext/Bundler/jsx
tsconfig.nestjs.json # Extends base, adds CommonJS/node
tsconfig.base.json (shared — NO module/moduleResolution here):
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true
}
}
tsconfig.vite.json (frontend apps):
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"isolatedModules": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["vite/client"]
}
}
App-level tsconfig (apps/web/tsconfig.json):
{
"extends": "@repo/typescript-config/tsconfig.vite.json",
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite-env.d.ts"]
}
5. HMR Strategy
Two strategies exist. Use A for active development, B for stable compiled packages.
Strategy A: resolve.alias to source (preferred)
Point resolve.alias to packages/<pkg>/src. Vite watches the TypeScript
source directly — changes trigger true HMR with state preservation.
Requirements:
- Package
exports in package.json must expose .ts source files
- No build step needed during development
optimizeDeps.exclude must list the package
{
"name": "@repo/ui",
"type": "module",
"exports": {
"./atoms": "./src/atoms/index.ts",
"./molecules": "./src/molecules/index.ts"
}
}
Strategy B: server.watch + optimizeDeps.exclude (compiled packages)
For packages that must be pre-compiled (e.g., packages with CSS transforms):
server: {
watch: { ignored: ['!**/node_modules/@repo/**'] },
},
optimizeDeps: {
exclude: ['@repo/ui'],
},
Limitations:
- Package must run
tsc --watch in a separate terminal
- Changes trigger a full page reload, not true HMR
- Both
watch.ignored AND optimizeDeps.exclude are required together
HMR-safe component patterns
Components must be pure and props-driven for HMR to work:
export function BikeCard({ bike }: { bike: Bike }) {
return <div className="bike-card">{bike.model}</div>
}
let counter = 0
export function BikeCard({ bike }: { bike: Bike }) { ... }
6. Package Exports
Sub-path exports allow fine-grained imports and tree shaking:
{
"name": "@repo/ui",
"type": "module",
"exports": {
"./atoms": "./src/atoms/index.ts",
"./molecules": "./src/molecules/index.ts",
"./globals.css": "./src/globals.css"
}
}
Dev vs production exports
| Approach | Build step | HMR | When |
|---|
"./atoms": "./src/atoms/index.ts" | No | True HMR | Active development (default for POC) |
"./atoms": "./dist/atoms/index.js" | Yes (tsup/tsc) | Full reload | Published or stable packages |
For this POC, point exports to ./src/ source. Add a build step later
if packaging for external consumption.
Package "type": "module"
Every package in the monorepo MUST set "type": "module". Mixing CJS and ESM
causes duplicate module instances (Vite issue #13538).
7. Dev Proxy
Vite's dev server proxies API requests to the backend, eliminating hardcoded
ports in client code.
server: {
proxy: {
'/api': {
target: process.env.VITE_API_TARGET || 'http://localhost:3000',
changeOrigin: true,
},
},
},
Usage in client code
const response = await fetch('/api/bikes')
Path rewrite decision
| NestJS global prefix | Vite proxy config |
|---|
app.setGlobalPrefix('api') | No rewrite needed — paths match |
| No prefix | Add rewrite: (path) => path.replace(/^\/api/, '') |
changeOrigin: true rewrites the Host header to match the target — required
for most backend frameworks.
8. Environment Variables
Vite env scoping
| Variable prefix | Scope | Access | Bundled |
|---|
VITE_* | Client (browser) | import.meta.env.VITE_KEY | Yes (static replacement) |
| Any other | Server only | process.env.KEY in vite.config.ts | No |
File loading priority (last wins)
.env # always loaded
.env.local # always loaded, git-ignored
.env.[mode] # mode-specific (development, production)
.env.[mode].local # mode-specific, git-ignored
Type-safe env vars
Create src/vite-env.d.ts:
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
Key rules
VITE_* variables are statically replaced at build time — they are NOT
runtime env vars. Changing them requires a rebuild.
- In dev with proxy configured,
VITE_API_URL is typically unnecessary —
fetch('/api/...') hits the proxy directly.
.env.local files MUST be git-ignored (may contain tokens for local dev).
NODE_ENV and Vite mode are separate concepts — --mode staging does
NOT set NODE_ENV=staging.
9. React Router v7
Setup with createBrowserRouter
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <RootError />,
children: [
{ index: true, element: <HomePage />, loader: homeLoader },
{
path: 'bikes',
element: <BikesPage />,
loader: bikesLoader,
children: [
{ path: ':bikeId', element: <BikeDetail />, loader: bikeLoader },
],
},
],
},
])
function App() {
return <RouterProvider router={router} />
}
Data loader pattern
export async function bikesLoader() {
const res = await fetch('/api/bikes')
if (!res.ok) throw new Response('Failed', { status: res.status })
return res.json()
}
function BikesPage() {
const bikes = useLoaderData() as Bike[]
return <ul>{bikes.map(b => <BikeCard key={b.id} bike={b} />)}</ul>
}
Error boundary
function RootError() {
const error = useRouteError()
return (
<div>
{error instanceof Response
? `Error ${error.status}: ${error.statusText}`
: String(error)}
</div>
)
}
Loading state
function RootLayout() {
const navigation = useNavigation()
return (
<div>
{navigation.state !== 'idle' && <LoadingBar />}
<Outlet />
</div>
)
}
Lazy loading routes
{
path: 'admin',
lazy: () => import('./routes/AdminPanel'),
}
Key v7 patterns vs v6
| v6 | v7 |
|---|
<BrowserRouter> + <Routes> | createBrowserRouter() + <RouterProvider> |
useEffect for data fetching | loader functions on route objects |
<form onSubmit> | <Form> from react-router-dom |
useHistory | useNavigate |
10. API Client Pattern
Typed fetch wrapper in packages/api-client/:
const BASE_URL = '/api'
export async function apiFetch<T>(
path: string,
options?: RequestInit,
): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...(options?.headers ?? {}),
},
...options,
})
if (!res.ok) {
throw new ApiError(res.status, await res.text())
}
return res.json() as Promise<T>
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
this.name = 'ApiError'
}
}
import type { Bike, CreateBikeDto } from '@repo/shared-types'
export const bikesApi = {
list: () => apiFetch<Bike[]>('/bikes'),
get: (id: string) => apiFetch<Bike>(`/bikes/${id}`),
create: (data: CreateBikeDto) =>
apiFetch<Bike>('/bikes', {
method: 'POST',
body: JSON.stringify(data),
}),
}
Usage in loaders
import { bikesApi } from '@repo/api-client'
export async function bikesLoader() {
return bikesApi.list()
}
11. Production Build
Manual chunks for vendor splitting
build: {
target: 'es2020',
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
},
},
},
},
Tree shaking requirements
- All packages MUST use ES module exports (no
module.exports)
- Prefer named exports over default exports from package entry points
- Sub-path exports (
"./atoms", "./molecules") produce smaller chunks
than barrel index.ts re-exports
Turborepo build pipeline
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"]
}
}
}
"dependsOn": ["^build"] ensures packages build before apps that depend
on them. Turborepo caches outputs — unchanged packages skip rebuilds.
Note: Turborepo v2+ uses "tasks" key. "pipeline" is v1 syntax (deprecated).
12. Anti-Patterns
| Anti-pattern | Symptom | Fix |
|---|
tsconfig paths without Vite alias | TS compiles, runtime fails with "module not found" | Mirror in resolve.alias or use vite-tsconfig-paths |
Missing server.fs.allow | 403 errors importing from workspace packages | Add searchForWorkspaceRoot(process.cwd()) |
Hardcoded ports in vite.config.ts | Port conflicts, non-portable config | Load from env var per random-ports rule |
optimizeDeps.exclude without server.watch | HMR still broken for workspace packages | Both settings required together (Strategy B) |
| Module-level side effects in shared packages | Full page reload instead of HMR | Move side effects into useEffect or startup functions |
Missing "type": "module" in package.json | Duplicate module instances, CJS/ESM conflicts | Set on every package in the monorepo |
"pipeline" in turbo.json | Deprecated warning, may break in Turborepo v3 | Use "tasks" key (v2+ syntax) |
workspace:^ on internal packages | Semver restrictions on in-repo packages (meaningless) | Use workspace:* (always latest local) |
Pre-bundling workspace packages (optimizeDeps.include) | Defeats source-import HMR | Only include third-party CJS libs |
Barrel index.ts re-exporting everything | Kills tree shaking, larger bundles | Use sub-path exports |
Sharing tsconfig base with module: CommonJS | Vite requires ESNext/Bundler — CommonJS breaks bundling | Separate tsconfig.vite.json and tsconfig.nestjs.json |
13. Cross-References
| Topic | Reference |
|---|
| Port assignment | random-ports rule — ephemeral range, env var loading |
| Env var handling | yaml-config-preference rule — externalize environment-varying values |
| Toolchain isolation | toolchain-isolation rule — pnpm, fnm, no system Node |
| Cross-platform | cross-platform-portability rule — forward slashes, no POSIX-only paths |
| NestJS backend | Project CLAUDE.md — stack definition, module structure |
| Testing (Vitest) | Out of scope for this skill — separate concern |