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:
    1. Local-only vs published packages (what's purely internal vs what goes to a registry)
    2. Direct-export (src/) vs pre-built (dist/) libraries
    3. 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

GitHub

References: Managing TypeScript Packages in Monorepos