Externalizing Dependencies in Library Bundles
Stop a library build from inlining its dependencies: derive externals from package.json, handle node: builtins and subpath imports, and verify nothing was bundled.
A library build that inlines its dependencies produces output that looks fine and behaves badly: the consumer installs one copy through their own tree and receives a second copy baked into your files. The fingerprint is a dist/ far larger than the source that produced it:
src/ total 18 kB
dist/index.mjs 412 kB ← something else is in there
Application bundles are meant to inline everything. Library bundles are meant to transform your own source and leave every dependency as an import statement, so the consumer’s package manager resolves and de-duplicates it. This guide covers configuring that correctly in the three common build tools, and verifying it afterwards.
Root Cause
Bundlers default to application behaviour: follow every import, inline everything reachable, produce a self-contained artefact. That is the right default for the thing that runs in a browser and the wrong one for a package that will be installed as a dependency of something else.
The consequence is not only size. An inlined dependency cannot be de-duplicated with the consumer’s copy, cannot be patched by their package manager when a vulnerability is published, and — for anything holding module-level state — behaves as a second instance in exactly the way a misclassified peer dependency does.
Minimal Reproduction
// tsup.config.ts — no externals configured
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
});
// src/index.ts
import { z } from "zod";
import { format } from "date-fns";
export const parseUser = (input: unknown) =>
z.object({ name: z.string(), joined: z.date() }).parse(input);
export const joinedOn = (d: Date): string => format(d, "yyyy-MM-dd");
npx tsup && ls -la dist/
grep -c "ZodObject" dist/index.mjs
-rw-r--r-- 412K dist/index.mjs
57
Both dependencies are now part of your published files, and every consumer receives them regardless of what their own tree already contains.
What Each Build Produces
Step-by-Step Fix
1. Derive the externals list from the manifest
A hand-written list drifts the first time somebody adds a dependency. Reading it from package.json at build time makes drift impossible.
// tsup.config.ts
import { defineConfig } from "tsup";
import pkg from "./package.json" with { type: "json" };
const externals = [
...Object.keys(pkg.dependencies ?? {}),
...Object.keys(pkg.peerDependencies ?? {}),
...Object.keys(pkg.optionalDependencies ?? {}),
];
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
external: [...externals, /^node:/],
});
Expected result: dist/index.mjs shrinks to roughly the size of your own source, and begins with import statements naming each dependency.
2. Cover subpath imports of the same packages
An externals entry for "date-fns" does not necessarily cover import { format } from "date-fns/format". A regular expression anchored to the package name handles both:
const externalPatterns = externals.map(
(name) => new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\/.*)?$`)
);
export default defineConfig({
external: [...externalPatterns, /^node:/],
});
3. Configure the equivalent in Rollup or esbuild
// rollup.config.mjs
import { readFileSync } from "node:fs";
const pkg = JSON.parse(readFileSync("./package.json", "utf8"));
export default {
input: "src/index.ts",
output: [
{ file: "dist/index.mjs", format: "es" },
{ file: "dist/index.cjs", format: "cjs" },
],
external: (id) =>
/^node:/.test(id) ||
[...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.peerDependencies ?? {})]
.some((dep) => id === dep || id.startsWith(`${dep}/`)),
};
# esbuild, same idea from the CLI
npx esbuild src/index.ts --bundle --format=esm --outfile=dist/index.mjs \
--external:zod --external:date-fns --external:'node:*'
HAZARD PREVENTION
Symptom: The ESM output is clean, but
dist/index.cjsstill contains dependency source and an__toESMhelper.Root cause: Some build configurations apply externals per-format, and a CommonJS output produced by a separate pass picked up defaults instead of the shared list.
Fix: Assert on both outputs in the verification step rather than only the ESM one — the CommonJS build is the one that regresses unnoticed, because far fewer people read it.
4. Decide deliberately about the browser build
If you also ship a bundled artefact for direct browser use, that build is the one exception where inlining is correct — it has no package manager to resolve anything. Keep it as a separate, clearly-named output rather than changing the main build’s behaviour:
export default defineConfig([
{ entry: ["src/index.ts"], format: ["esm", "cjs"], external: externals, dts: true },
{ entry: ["src/index.ts"], format: ["iife"], globalName: "MyLib", outExtension: () => ({ js: ".global.js" }), minify: true },
]);
Verification
# 1. output size is in the same order as the source
du -sh src dist
# 2. no dependency source was inlined, in either format
for f in dist/index.mjs dist/index.cjs; do
grep -q "ZodObject" "$f" && echo "FAIL: zod inlined in $f" || echo "ok: $f"
done
# 3. dependencies appear only as import specifiers
node --input-type=module -e '
import { readFileSync } from "node:fs";
const src = readFileSync("dist/index.mjs", "utf8");
const specs = [...src.matchAll(/from\s+"([^".][^"]*)"/g)].map(m => m[1]);
console.log("external specifiers:", [...new Set(specs)].join(", "));
'
Expected: a dist/ comparable in size to src/, two ok: lines, and a specifier list naming each dependency exactly once.
Edge Cases / Gotchas
noExternaloverridesexternal. A package listed in both is bundled; this is easy to hit when a shared config sets one and a package-level config sets the other.- Declaration bundling has its own externals setting.
rollup-plugin-dtsand API Extractor can inline a dependency’s types even when the runtime build externalised its code, producing a.d.tsthat duplicates upstream type definitions. - Externalising a package that ships only CommonJS costs consumers an interop shim. Sometimes bundling that one dependency is genuinely better; measure rather than applying the rule blindly.
- Workspace-internal packages need a decision of their own. A
workspace:sibling is a real dependency at publish time, so it should be external — unless it is a private helper package that never gets published, in which case it must be bundled or the published package will reference something that does not exist. - Externals do not apply to assets. CSS, JSON and other non-JavaScript imports follow separate rules in most bundlers, and an externalised JSON import will fail at runtime for consumers whose loader does not support it.
Frequently Asked Questions
Why does an externals list of package names miss subpath imports?
Most bundlers match externals by exact specifier unless told otherwise, so an entry for a package name does not cover an import of that package’s subpath. Either list the subpaths explicitly or use a regular expression anchored to the package name followed by an optional slash.
Should node: builtins be marked external?
They are external by default in Node-targeted builds, but a browser-targeted build will try to polyfill or fail on them. Adding a node: pattern makes the intent explicit and turns a silent polyfill injection into a build failure, which is the more useful outcome for a library.
Does externalizing hurt consumers who do not bundle?
No. A Node.js consumer resolves your import statements through node_modules exactly as they resolve any other package. The only affected consumers are those loading files directly in a browser without a build step, and for them the answer is a separate bundled artefact.
How do I know whether something was inlined?
Search the built output for a distinctive string from the dependency’s source, and compare output size against your own source. Inlined dependencies also leave interop helpers such as __toESM in an otherwise clean ESM build.
What about devDependencies — do they need externalizing?
They should never appear in the output at all. If one is inlined, something in src/ imports it at runtime rather than only in tests or build scripts — fix the import rather than adding the package to the externals list.
Related
- Reducing Dependency Weight in Published Packages — where this fits in the overall footprint picture.
- Modern Build Tools: tsup, Rollup, and esbuild — choosing the tool whose externals model you will be configuring.
- Auditing Transitive Dependency Size Before Publish — measuring what those external dependencies cost once installed.