The three Nx library types, defined precisely
Nx distinguishes libraries by what they can produce on their own, not by what code they contain. The same TypeScript module can be any of the three types. Only the project configuration changes.
Non-buildable libraries have no build target. A TypeScript path alias in the workspace's base tsconfig maps an import such as @acme/orders-domain to the library's source. The consuming app's bundler then compiles that source as if it lived inside the app. This keeps setup minimal, but every change to the library means the app recompiles it.
Buildable libraries have their own build target, which emits compiled JavaScript and type declarations to an output directory. Because the build is a separate task with defined inputs and outputs, Nx can cache it and skip it when nothing relevant has changed. You can also build the library alone, which is useful for checking that it compiles without any app around it.
Publishable libraries are buildable libraries that are also valid npm packages. They need a registry-valid package name (usually scoped), a version, and a package.json that declares runtime dependencies so consumers outside the monorepo can install it correctly.
| Type | Build target | Output | Compile cached separately | Ready for npm publish | Config footprint |
|---|---|---|---|---|---|
| Non-buildable | None | None; compiled inside the app | No | No | Path alias only |
| Buildable | Yes | Compiled JS and type declarations | Yes | No | Build target and build tsconfig |
| Publishable | Yes | Compiled package with package.json | Yes | Yes | Build config, package metadata, version |
Generator options and executor names have changed across Nx major versions. Check the flags against the documentation for the version you have installed.
How a backend app actually consumes each library type
In a typical integrated Nx workspace, the app never touches a library's dist folder. The root tsconfig.base.json maps an import alias straight to source, for example "@acme/db": ["libs/db/src/index.ts"] under compilerOptions.paths. When the API's bundler resolves import { pool } from '@acme/db', it follows that alias into the TypeScript files and compiles them as part of the app. This holds for all three library types, so the app consumes a buildable library's source by default, not its build output.
The two flows look like this:
- Source via alias (default):
apps/api→tsconfig paths→libs/db/src/*.ts→ app bundler →dist/apps/api - Compiled output:
libs/db→ its own build target →dist/libs/db→ app build, configured to use compiled dependencies →dist/apps/api
The library's compiled output only enters the second path when the app's build is explicitly configured to consume compiled dependencies. Without that setting, building the library first changes nothing the app uses.
This creates a double-compilation trade-off. The library's own target compiles it one way, with its own tsconfig.lib.json, module format and output layout. The app bundler compiles the same source a second way, with the app's settings. Tests and local runs exercise only the second path. Bugs that exist only in the first path, such as CommonJS/ESM mismatches, broken relative paths to non-TypeScript assets, or missing exports entries, stay hidden until something consumes dist/libs/db directly. That consumer is often a publish step or a separate service.
// tsconfig.base.json (tsconfig files accept comments)
// Integrated workspace: every import alias resolves to library SOURCE,
// not to anything under dist/.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
// import { pool } from '@acme/db'
// -> the app bundler reads this .ts file and compiles it with the
// app's settings. dist/libs/db is never consulted, even if the
// library has its own build target and was built first.
"@acme/db": ["libs/db/src/index.ts"],
// Same mechanism for every library type (non-buildable,
// buildable, publishable).
"@acme/auth": ["libs/auth/src/index.ts"]
}
}
}
// Two separate compilations of the same source:
// 1) nx build db -> libs/db/tsconfig.lib.json -> dist/libs/db
// (its own module format, output layout, package.json exports)
// 2) nx build api -> follows the alias above -> dist/apps/api
// Tests and local runs only exercise (2). A CJS/ESM mismatch or a bad
// relative asset path that exists only in (1) stays hidden until
// something imports dist/libs/db directly.
A root tsconfig.base.json path alias that points @acme/db at the library's TypeScript source, which is why the app bundler compiles the library itself and ignores its separately built dist output by default.
Incremental builds: how they work and when they pay off
The same build targets that expose those problems are also what make incremental builds possible. Each buildable library gets its own build task. Nx hashes that task's inputs (source files, config, and the outputs of its dependencies). If the hash matches a previous run, Nx restores the output from cache instead of compiling again. A change to one library rebuilds that library and its dependents. Unrelated libraries are not rebuilt.
This only works under two conditions:
- The app must build against compiled outputs. If the service's bundler resolves library source through path aliases, it recompiles that source on every build, and the cached library builds go unused.
- Caching must persist between runs. A local cache helps on a developer laptop. CI runners are usually ephemeral, so without remote caching every pipeline starts cold. In that case CI gains much less, and may gain nothing.
| Workspace (illustrative estimate) | Assumed inputs | Likely outcome |
|---|---|---|
| Small | 3 services, 15 small libraries, full service build around 30 s, each extra task adding roughly 1–2 s of scheduling and cache overhead | Overhead from 15 extra tasks can match or exceed the compile time saved |
| Large | 1 service depending on 40 libraries at about 20 s each, a typical change touching 2 of them, remote cache enabled | Most library builds are restored from cache, and rebuild time shrinks to the changed libraries plus the app |
These figures are assumptions, not measurements. Before converting anything, profile your current local build times and CI wall-clock times. Check where the time actually goes, and only convert libraries if compiling them is the bottleneck.
Making a library publishable for npm publish
A publishable library is a buildable library with a package manifest that external consumers can install. To convert an existing library, start with its own package.json and keep it minimal. It needs a scoped name that matches the import path the workspace already uses, so internal and external consumers resolve the same identifier. It needs a version that your release process controls. It needs main or exports pointing at the compiled output rather than the TypeScript source. It needs dependencies for runtime packages the library imports, and peerDependencies for anything the consuming service should own, such as a framework or a database driver. The example that follows shows these fields together.
The most common publishing failure involves dependencies. Inside the workspace, most installs hoist packages into a shared root node_modules, so an undeclared import still resolves through Node's lookup algorithm. Your tests pass, the app builds, and nothing warns you. An external consumer installs only what your manifest lists. The first time their service loads the missing module, it fails at startup. Every third-party import in the library's source must therefore appear in its own package.json, with a version range compatible with the workspace.
Nx's ESLint plugin includes a dependency-checks rule that compares imports against the manifest and flags missing or unused entries. Enable it on publishable libraries. Do not treat the generated manifest as correct by default, because build executors infer dependencies and can miss dynamic or conditional imports. Before running npm publish, open the package.json in the build output directory and read it. Running npm pack lets you inspect the exact tarball contents.
{
"name": "@acme/billing-core",
"version": "1.4.0",
"type": "commonjs",
"main": "./src/index.js",
"types": "./src/index.d.ts",
"exports": {
".": {
"types": "./src/index.d.ts",
"default": "./src/index.js"
},
"./package.json": "./package.json"
},
"files": [
"src",
"README.md"
],
"dependencies": {
"decimal.js": "^10.4.3",
"zod": "^3.23.8"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0",
"pg": "^8.11.0"
},
"publishConfig": {
"access": "public"
}
}
A minimal package.json for a publishable library: a scoped name matching the workspace import path, entry points aimed at compiled JavaScript in the build output, runtime imports under dependencies, and service-owned packages under peerDependencies.
Backend-specific concerns: module formats, drivers and secrets
Module format and compile target
Pick CommonJS or ESM output based on how the consuming service loads code, not on library-author preference. A Node service that uses require can fail to load an ESM-only package, and an ESM service importing CommonJS may find that named exports do not resolve as expected. TypeScript checks your types against declaration files, not against what the runtime loader will do, so these failures appear only when the process starts. Set the TypeScript target to the oldest Node version your services actually run. A target that is too new emits syntax older runtimes cannot parse. A target that is too old adds unnecessary down-leveled code.
Native, optional and driver dependencies
Never bundle packages that ship compiled native binaries. Their binaries are built for a specific platform and ABI, so mark them external and declare them so the consumer's install fetches the correct build. Put database drivers, ORMs and similar packages in peerDependencies. If two copies load in one process, they create separate connection pools and break instanceof checks. Using peer dependencies also lets each service control the driver version. Use optionalDependencies only for code paths that handle the package being absent.
Secrets and configuration
Read credentials and environment-specific values at runtime, from the environment or a config service. Do not inline them at build time, because a substituted value ships to everyone who can install the package. Publish to a scoped private registry with tokens that are limited to publish rights. Restrict the files field in package.json and run npm pack --dry-run to confirm that no .env files, fixtures or source maps with internal paths are included.
{
"name": "@acme/orders-db",
"version": "1.4.0",
"type": "commonjs",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js"
}
},
"files": [
"dist/**/*.js",
"dist/**/*.d.ts"
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"pg": "^8.0.0"
},
"optionalDependencies": {
"pg-native": "^3.0.0"
},
"publishConfig": {
"access": "restricted"
}
}
A package.json for a scoped CommonJS backend library that declares the pg driver as a peer dependency and pg-native as optional, sets a Node engine floor, ships only compiled JS and type declarations, and publishes with restricted access; check the result with npm pack --dry-run.
Decision guide: who should use which type and when to avoid each
Start from the cheapest type and move up only when a specific constraint forces it. Each step adds build configuration, output artifacts and maintenance work, so the extra type has to earn its place.
| Library type | Use it when | Avoid it when |
|---|---|---|
| Non-buildable | Code is consumed only by apps inside the workspace. This is the default for most backend teams. | A deployment step must compile the library without the app, or external repos need it. |
| Buildable | Profiling shows build time is the bottleneck, or the library must compile on its own, such as in a separate Docker build stage. | Builds are already fast, or the library changes in nearly every commit. In that case caching rarely hits and the per-library build overhead becomes pure cost. |
| Publishable | Repos or teams outside the monorepo consume the code through a registry. | Every consumer lives in the workspace. Versioning, changelogs and registry credentials then add work without solving a real problem. |
Before you make a library publishable, check whether one of these alternatives removes the need:
- Keep consumers inside the monorepo. Moving a service into the workspace often costs less than maintaining a versioned package for it.
- Share an API contract instead of code. Publish an OpenAPI or protobuf definition, or a client generated from it. Consumers then depend on the wire format rather than your internal types, and you can refactor the implementation freely.
- Vendor the code. Copying a small, stable module into the consuming repo works when it changes rarely. Accept that fixes will not propagate automatically.
Impact on cost and engineering time, and changing type later
Every library type you create carries an ongoing cost, and that cost scales with the number of libraries.
A buildable library adds three things you have to maintain: a build target in project.json, a build-specific tsconfig, and an output path. Across dozens of libraries these drift. One library ends up with a different module setting, another writes to a stale output directory, and new contributors have to learn which differences are intentional.
Illustrative estimate (assumed numbers, not a benchmark): annual hours = libraries × config changes per library per year × minutes per change ÷ 60. With 40 buildable libraries, 3 changes each, and 20 minutes per change: 40 × 3 × 20 ÷ 60 = 40 hours per year. That figure excludes onboarding time.
A publishable library costs more because it creates a version contract. Once a consumer pins a version, the monorepo's always-at-HEAD model no longer covers that consumer. You can't change the library and fix every caller in one atomic commit. A breaking change requires a major version bump, a release, and coordinated upgrades in each consuming repo. If you do publish, use semantic versioning, keep a changelog for each library, and host the packages in a private npm registry so internal code stays off the public one.
The choice is reversible. You can add a build target or a publish configuration to an existing library later without moving its code. Start with the least capable type that meets today's need. Upgrading later costs little, while build and release machinery you don't use costs engineering time every week.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx buildable vs publishable libraries, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.