Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Using Internal Packages Without a Build Step

Many monorepo packages are never published: shared UI components, API clients, utilities used only by the repository's own applications. Building them to dist/ before every application build, keeping watchers running during development, and ordering builds correctly in CI all cost time — and none of it matters to the applications, whose bundlers can compile TypeScript themselves. "Just-in-time" or source-only internal packages skip the build: the package's exports point at TypeScript source, and each consumer compiles it as part of its own build. This guide sets them up, explains which tools support them, and covers the cases where you still need a build.

How source-only packages work

A normal (compiled) internal package exposes dist/index.js; consumers need it built first. A source-only package exposes src/index.ts directly:

{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": "./src/index.ts",
    "./button": "./src/button/index.ts",
    "./styles.css": "./src/styles.css"
  },
  "peerDependencies": { "react": "^18.3.0 || ^19.0.0" },
  "devDependencies": { "react": "catalog:", "typescript": "catalog:" }
}

When apps/web imports @acme/ui, Vite, Next.js, webpack or esbuild resolve the package through node_modules to src/index.ts and compile it along with the application. TypeScript, type-checking apps/web, resolves the same file and checks it as part of the application's program. The broader alternatives are compared in TypeScript Project References in Monorepos.

Compiled internal packages versus source-only internal packages Compares packages that build to dist with packages that export TypeScript source, on build steps, dev loop, type-check cost and where they work. compiled (dist) source-only (src) Build before consumers required, ordered none Dev loop after an edit rebuild or watcher instant HMR Type-check work once per package once per consumer Bundled apps (Vite, Next) works works Node.js services, npm works needs compile or type stripping
Source-only packages remove build steps for bundled apps; compiled packages remain necessary for Node.js runtimes and publishing.

Tool support and configuration

Vite compiles TypeScript from node_modules workspace packages out of the box, because workspace packages resolve to paths outside node_modules (through the symlink's real path) and are treated as source. HMR works across packages.

Next.js needs to be told to compile the package, because it skips transpiling node_modules by default:

// apps/web/next.config.js
module.exports = {
  transpilePackages: ['@acme/ui', '@acme/utils'],
};

webpack needs its TypeScript loader rule to include the workspace packages (for example, by not excluding paths under packages/).

TypeScript must use moduleResolution: "bundler" (or node16/nodenext with .ts extensions allowed) in consumers, and the package's source must be valid under the consumer's settings — shared compiler options from a base config keep that consistent:

{
  "extends": "@acme/tsconfig/app-bundler.json",
  "compilerOptions": {
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "noEmit": true
  },
  "include": ["src"]
}

Test runners — Vitest handles source packages like Vite; Jest needs its transform to include them, as covered in Resolving Symlinked Workspace Packages in Jest and Vitest.

A source-only package inside an application build The application imports @acme/ui, the bundler resolves the symlinked package to src/index.ts, compiles it with the app's settings, and emits one bundle. import '@acme/ui' apps/web/src/App.ts x resolve exports -> packages/ui/src/inde x.ts compile with app same TS/JSX settings one bundle tree-shaken with the app
The consumer's bundler does the package's compilation; there is no separate build step or dist folder.

Rules that keep source packages healthy

Because consumers compile the package with their settings, a few constraints apply:

  1. Use a shared compiler configuration. If apps/web uses the automatic JSX runtime and apps/admin uses the classic one, a component package must work under both — or, better, both apps extend the same base.
  2. Avoid path aliases inside the package. A paths alias in packages/ui/tsconfig.json means nothing to the consumer's compiler. Use relative imports or the imports field with # specifiers, which every modern bundler resolves — see Using Subpath Imports with the imports Field.
  3. Declare dependencies and peers in the package's own package.json, even though no build uses them — they drive task ordering, caching and correct resolution.
  4. Keep the package's own checks. Give it lint, test and typecheck scripts (tsc --noEmit in the package) so errors are caught in the package itself, not only when an application happens to import the broken file.

When you still need a build

Source-only packages are not universal:

  • Node.js services that run compiled output with node dist/server.js cannot import .ts from dependencies. Either bundle the service (so the bundler compiles the package) or use a compiled package. Node's type stripping does not apply inside node_modules.
  • Published packages must ship JavaScript and declarations.
  • Very large packages used by many applications make every consumer type-check them again, which can dominate type-check time. Project references with compiled declarations check them once.

A hybrid covers all three: compile to dist/ for Node.js consumers and publishing, and add a development or custom condition pointing at source for bundlers and tests:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "development": "./src/index.ts",
      "default": "./dist/index.js"
    }
  }
}

Enable the condition in dev servers and tests (resolve.conditions: ['development'] in Vite/Vitest, customConditions in tsconfig.json), and production builds keep using dist/.

Type-checking cost and how to contain it

The main cost of source-only packages shows up in type-checking. Every consumer's tsc --noEmit includes the source of every internal package it imports, so a large shared component library is checked once per application — three applications, three checks. In small repositories that cost is negligible; in large ones it can dominate CI time and editor memory.

Three techniques keep it contained. First, check each package once, in isolation, with its own typecheck script, and rely on that for correctness of the package's internals; the consumer's check then mostly confirms that the consumer uses the package correctly. Second, enable skipLibCheck, which skips checking declaration files but not .ts source, so it helps less than people expect here — know that before counting on it. Third, when a package becomes large enough to matter, switch it to the hybrid model: compile declarations with tsc -b so consumers read .d.ts files instead of source during type-checking, while dev servers keep using source through the development condition. That keeps the fast development loop and makes type-checking scale.

Framework-specific details

A few frameworks have their own switches beyond the general configuration above.

Next.js transpiles listed packages with its own compiler, and the App Router requires client components in those packages to keep their "use client" directive at the top of each file. Because source-only packages are not bundled separately, directives are preserved naturally — one of the reasons this model is popular for Next.js design systems.

React Native and Expo need Metro to watch the workspace folders (watchFolders) and resolve from the workspace root, and Metro's Babel preset compiles TypeScript from those packages. Declare React Native as a peer in shared packages to avoid duplicate copies.

Storybook compiles stories from source packages through its builder (Vite or webpack) exactly like an application, so the same configuration applies; point its stories globs at the package folders directly.

Server-side code in full-stack frameworks — Remix, SvelteKit, Nuxt — is bundled by the framework's build for both server and client, so source-only packages work on the server side too, as long as the server output is bundled rather than run with plain Node.js from source.

Task runner configuration

With source-only packages, applications no longer depend on packages' build tasks. Adjust the task graph so the runner does not wait for builds that do not exist:

{
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"] },
    "typecheck": { "dependsOn": ["^typecheck"] },
    "dev": { "cache": false, "persistent": true }
  }
}

Source-only packages simply have no build script, so ^build skips them, while their source still counts as an input of the consuming application's build — task runners include dependencies' files in the hash, so changing packages/ui/src invalidates apps/web#build.

Task graph with source-only packages The web app's build depends only on compiled packages' builds; source-only ui and utils have no build task, but their files are inputs to the app build. @acme/ui source-only: no build @acme/api-client compiled: build task apps/web#build hashes ui source + client dist
Source-only packages disappear from the build graph but stay in the input hash of every consumer.

Worked example: removing watchers from local development

A team's pnpm dev started six library watchers alongside two applications, and edits to shared components took several seconds to appear as the watcher rebuilt, then the app reloaded. Converting the four UI and utility packages to source-only removed four watchers; edits now appear via Vite's hot module replacement instantly. The API client, used by a Node.js service, stays compiled with a development condition for the web app. CI's build stage shrinks because four package builds no longer run, and type-checking stays per package through each package's own typecheck script.

Prevention and guardrails

  • Share compiler settings through a base configuration so every consumer compiles packages the same way.
  • No internal paths aliases in source-only packages; use relative imports or # imports.
  • Keep per-package typecheck and test so errors are caught where they are introduced.
  • Use compiled or hybrid packages for Node.js runtimes and anything published.

Frequently Asked Questions

Does this make application builds slower? Slightly, because the bundler compiles the package source. In practice, modern bundlers compile TypeScript very quickly, and removing separate package builds usually makes the overall pipeline faster.

Do source-only packages break tree-shaking? No — they usually improve it, because the bundler sees the original ES modules and can eliminate unused exports precisely.

Can I publish a source-only package later? Add a build step and point exports at dist/ (keeping a development condition if useful). The package's public API does not change.

How do I stop someone publishing a source-only package by accident? Keep "private": true in its package.json. npm and pnpm refuse to publish private packages, and release tools such as Changesets skip them.

Related

TypeScript Project References in Monorepos