TypeScript monorepos without the drama (pnpm + Vite)
TL;DR
- Workspaces link packages locally and give you one dependency graph. They don't build or type-check anything by themselves.
- Three axes to decide up front:
- Local-only vs published packages (what's purely internal vs what goes to a registry)
- Direct-export (
src/
) vs pre-built (dist/
) libraries - Tooling: Vite for apps, Rollup/Vite library mode for libs
- For the fastest inner loop: local-only + direct-export + Vite
- For anything published: separate build + type declarations
Axis 1 - Local-only vs published packages
If we further distinguish between internally published and externally published packages then we can describe three types:
- local only - not published, may, or may not, have a build step.
- internally published - published to an internal registry, may, or may not, have a build step
- externally published - published to a public registry; always have a build step
References: vite-ts-monorepo-rfc, Turborepo
Axis 2 - Direct-export vs pre-built
Two ways to make a library consumable:
Direct-export (src/
)
Point your exports
at the TypeScript source so it can be compiled on the fly.
// package.json
{
"name": "@acme/lib-a",
"exports": { ".": "./src/index.ts" },
}
When to use: local-only libs, or other internal repositories you control
Trade-offs: consumers must have a TypeScript pipeline; no single compiled artifact; slower cold builds in big apps.
Pre-built (dist/
)
Run a build first and export compiled output + declarations.
// package.json
{
"name": "@acme/lib-b",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"types": "./dist/index.d.ts"
}
When to use: anything published externally.
Hybrid: conditional exports for DX in dev, stable dist/
in prod:
// package.json (hybrid)
{
"exports": {
".": { "development": "./src/index.ts", "default": "./dist/index.js" }
},
"types": "./dist/index.d.ts"
}
And in the app’s Vite config:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => ({
resolve: { conditions: mode === 'development' ? ['development'] : [] }
}));
Axis 3 - Tooling: where Vite and Rollup fit
- Vite is your app dev server + production bundler (Rollup under the hood). It transpiles TypeScript with esbuild, does not type-check.
- Library bundlers: Rollup/Vite library mode to emit ESM/CJS plus
.d.ts
(via plugin).
Baseline that works:
- Apps: Vite for dev/build.
- Libraries: Build JavaScript with Vite/Rollup. Emit types with
vite-plugin-dts
.
Publishing clean artifacts with pnpm
Bundle internal libraries and create a clean package.json
without the bundled dependencies.
Example repository
References: Managing TypeScript Packages in Monorepos