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.
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.
Rules that keep source packages healthy
Because consumers compile the package with their settings, a few constraints apply:
- Use a shared compiler configuration. If
apps/webuses the automatic JSX runtime andapps/adminuses the classic one, a component package must work under both — or, better, both apps extend the same base. - Avoid path aliases inside the package. A
pathsalias inpackages/ui/tsconfig.jsonmeans nothing to the consumer's compiler. Use relative imports or theimportsfield with#specifiers, which every modern bundler resolves — see Using Subpath Imports with the imports Field. - 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. - Keep the package's own checks. Give it
lint,testandtypecheckscripts (tsc --noEmitin 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.jscannot import.tsfrom dependencies. Either bundle the service (so the bundler compiles the package) or use a compiled package. Node's type stripping does not apply insidenode_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.
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
pathsaliases in source-only packages; use relative imports or#imports. - Keep per-package
typecheckandtestso 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 compares this model with compiled references.
- Converting Shared Code into Internal Workspace Packages creates the packages this guide configures.
- Configuring Persistent Dev Tasks in Turborepo shows the watcher setup this approach replaces.
- Understanding package.json Fields explains the
exportsconditions used for hybrid packages.