esbuild 07 ⚡ External Packages — –packages=external for backend builds
The --packages=external option tells esbuild to omit every import that looks like a package import from the bundle. The import statement remains in the output, and Node resolves it at runtime from node_modules. This is the default behavior for platform: 'node' since esbuild 0.22.0, but the explicit flag is still the correct way to document the intent in a build script and the way to override the default when a project has changed it .
This chapter covers the --packages setting and its values, the detection rule that determines what counts as a package import, the difference between --packages=external and --external:, and the consequence that the build output is no longer self-contained: the node_modules directory must be present when the bundle runs.
Key point: --packages=external externalizes every bare import (react, express, @scope/pkg, pkg/subpath), but not relative or absolute paths. The bundle contains the application code, and the dependencies are resolved from the filesystem at runtime.
Why the default changed
The bundler-incompatible package problem. Some npm packages are not designed to be bundled. They use __dirname to locate assets, they spawn child processes, they load native .node binaries, or they use dynamic require calls that a static bundler cannot analyze. When esbuild attempted to bundle them for a Node target, the resulting bundle failed at runtime with errors about missing files or unresolved modules. The esbuild changelog describes the confusion: people using esbuild for a Node build expected it to work like Node, but the bundler’s default behavior was to inline everything .
The behavioral shift in 0.22.0. The release notes state the change directly: “With this release, esbuild will now omit packages from the bundle by default when the platform is node (i.e. the previous behavior of --packages=external is now the default in this case).” The --packages=bundle value restores the previous behavior of allowing packages to be inlined. The explicit --packages=external flag is still useful for documenting intent and for compatibility with build scripts that predate the default change .
a. The –packages values
The setting accepts three values: external, bundle, and the implicit default based on the platform.
| Value | Behavior |
|---|---|
external | Every package import is left in the output |
bundle | Packages are allowed to be bundled |
Default (platform: 'node') | external behavior |
Default (platform: 'browser') | bundle behavior |
// esbuild.config.js
esbuild.build({
entryPoints: ['src/server.js'],
bundle: true,
platform: 'node',
packages: 'external',
outfile: 'dist/server.js',
})
The packages: 'external' line is redundant on platform: 'node' in modern esbuild, but it makes the build script’s intent explicit and protects against a future default change or a configuration that accidentally overrides the platform .
b. What counts as a package import
The detection rule is syntactic, not semantic. The changelog defines it precisely: “import paths that don’t start with a path segment of / or . or .. are considered to be package imports.” The two exceptions are subpath imports (which start with #) and TypeScript path remappings (paths and baseUrl in tsconfig.json), which are applied before the package detection rule .
| Import path | Externalized by --packages=external |
|---|---|
react | Yes |
@scope/pkg | Yes |
pkg/subpath | Yes |
./local | No |
../parent | No |
/absolute | No |
#internal | No (subpath import) |
@/alias (from tsconfig paths) | No (path remapping) |
A relative import is always bundled. The bundler follows the path, includes the file, and inlines its contents. A package import is left as a reference. This is why the build output still contains import express from 'express' after the bundle is produced.
c. –packages=external vs –external:
The two options operate at different levels. --packages=external is a blanket rule: every package import is external. --external: is a targeted rule: only the specified packages are external, and the rest are bundled.
// Externalize everything that looks like a package
esbuild.build({ packages: 'external' })
// Externalize only these packages, bundle the rest
esbuild.build({ external: ['sharp', 'canvas'] })
The targeted external list is appropriate when most dependencies are bundle-safe and only a few are not. The blanket packages: 'external' is appropriate for a backend build where the deployment environment installs dependencies from package.json and the bundle is only the application code. The changelog notes that --packages=bundle does not mean everything is bundled; individual packages can still be excluded with --external: .
The esbuild-node-externals plugin is the higher-level option for a backend build. It reads package.json and externalizes every dependency listed in dependencies, devDependencies, peerDependencies, and optionalDependencies by default. It supports allowList for packages that should be bundled despite being in package.json, and forceExternalList for packages that should be external despite not being declared . The plugin is useful when the build should externalize exactly what the package manager installed, not everything that looks like a package import.
Complete Example Session
// ============================================
// PART 1: THE BACKEND BUILD
// ============================================
// esbuild.config.js
const esbuild = require('esbuild')
esbuild.build({
entryPoints: ['src/server.js'],
bundle: true,
platform: 'node',
packages: 'external',
outfile: 'dist/server.js',
})
// The application code is bundled.
// Every package import is left as an import statement.
// ============================================
// PART 2: THE OUTPUT
// ============================================
// src/server.js
import express from 'express'
import { db } from './db.js'
const app = express()
app.get('/', (req, res) => res.send('ok'))
// dist/server.js (approximately)
import express from 'express'
// db.js contents inlined here
const app = express()
app.get('/', (req, res) => res.send('ok'))
// 'express' is still an import.
// './db.js' is inlined.
# ============================================
# PART 3: THE RUNTIME REQUIREMENT
# ============================================
# The bundle needs node_modules at runtime.
node dist/server.js
# If node_modules is missing:
# Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'express'
# ============================================
# PART 4: THE DEPLOYMENT PATTERN
# ============================================
# Copy the bundle and the production dependencies
cp dist/server.js /app/
cp package.json package-lock.json /app/
cd /app && npm ci --omit=dev
node server.js
# The bundle is small.
# The dependencies are installed by npm.
// ============================================
// PART 5: --packages=bundle
// ============================================
esbuild.build({
bundle: true,
platform: 'node',
packages: 'bundle',
outfile: 'dist/server.js',
})
// Packages are allowed to be bundled.
// The output is self-contained (if all packages are bundle-safe).
// ============================================
// PART 6: TARGETED EXTERNAL
// ============================================
esbuild.build({
bundle: true,
platform: 'node',
external: ['sharp', 'canvas'],
outfile: 'dist/server.js',
})
// sharp and canvas are external.
// Everything else is bundled.
// ============================================
// PART 7: THE esbuild-node-externals PLUGIN
// ============================================
const { nodeExternalsPlugin } = require('esbuild-node-externals')
esbuild.build({
bundle: true,
platform: 'node',
plugins: [nodeExternalsPlugin()],
outfile: 'dist/server.js',
})
// Externalizes everything in package.json dependencies.
// ============================================
// PART 8: THE PLUGIN ALLOWLIST
// ============================================
nodeExternalsPlugin({
allowList: ['my-pure-js-lib'],
})
// my-pure-js-lib is bundled despite being in package.json.
// ============================================
// PART 9: THE SUBPATH IMPORT EXCEPTION
// ============================================
// package.json imports field
{
"imports": {
"#config": "./config.js"
}
}
// import config from '#config'
// Not externalized by --packages=external.
// Treated as a path, resolved and bundled.
// ============================================
// PART 10: THE TSCONFIG PATH EXCEPTION
// ============================================
// tsconfig.json
{
"compilerOptions": {
"paths": { "@/*": ["./src/*"] }
}
}
// import { x } from '@/lib/x'
// Not externalized.
// The path remapping is applied first, then the file is bundled.
The ten parts covered the backend build, the output, the runtime requirement, the deployment pattern, --packages=bundle, targeted external, the plugin, the plugin allowlist, the subpath exception, and the tsconfig path exception.
Quick Reference
–packages values
| Value | Effect | Default platform |
|---|---|---|
external | Leave all package imports | node |
bundle | Allow packages to be bundled | browser |
What is externalized
| Import | Externalized |
|---|---|
react | Yes |
@scope/pkg | Yes |
pkg/subpath | Yes |
./local | No |
../parent | No |
/absolute | No |
#subpath | No |
@/alias (tsconfig) | No |
Related options
| Option | Scope |
|---|---|
packages: 'external' | All package imports |
external: ['sharp'] | Named packages only |
esbuild-node-externals | package.json dependencies |
allowList (plugin) | Bundle despite package.json |
forceExternalList (plugin) | External despite not in package.json |
Deployment consequence
| Bundle type | Runtime requirement |
|---|---|
packages: 'external' | node_modules must exist |
packages: 'bundle' | Self-contained (if bundle-safe) |
Best Practices
✅ Do This:
// Set packages: 'external' explicitly for backend builds
packages: 'external' // ✅
// Use the plugin to externalize exactly what npm installed
plugins: [nodeExternalsPlugin()] // ✅
// Deploy the bundle with package.json and install production deps
npm ci --omit=dev // ✅
// Use external: for packages that must not be bundled
external: ['sharp', 'canvas'] // ✅
❌ Don’t Do This:
// Assume the bundle is self-contained with packages: 'external'
// node_modules is required at runtime // ❌
// Use packages: 'bundle' for native modules
// The .node binary cannot be inlined // ❌
// Forget the runtime dependencies in the Docker image
// The bundle fails on the first import // ❌
// Use --packages=external for a browser build
// The browser has no node_modules // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
Cannot find package 'express' at runtime | packages: 'external' but no node_modules | Install production dependencies in the deployment |
| Native module fails to load | Bundled instead of externalized | Add to external or use the plugin |
| Subpath import externalized unexpectedly | The # prefix is not a package import | The rule excludes # imports; check the import path |
| tsconfig alias externalized | The paths remapping is applied first | The rule excludes remapped paths; check tsconfig.json |
| Bundle is huge | packages: 'bundle' inlined everything | Switch to packages: 'external' |
Real-World Examples
1. Explicit external packages
esbuild.build({ bundle: true, platform: 'node', packages: 'external' })
2. Bundle everything
esbuild.build({ bundle: true, platform: 'node', packages: 'bundle' })
3. Targeted external
esbuild.build({ bundle: true, external: ['sharp', 'canvas'] })
4. Plugin
const { nodeExternalsPlugin } = require('esbuild-node-externals')
plugins: [nodeExternalsPlugin()]
5. Plugin allowlist
nodeExternalsPlugin({ allowList: ['pure-js-lib'] })
6. Production install
npm ci --omit=dev
7. Bundle plus dependencies
cp dist/server.js /app/ && cd /app && npm ci --omit=dev
8. Subpath import
import config from '#config' // not externalized
9. tsconfig alias
import { x } from '@/lib/x' // not externalized
10. Docker layer
COPY dist/ /app/
COPY package*.json /app/
RUN npm ci --omit=dev
Visual
The two build modes
┌─────────────────────────────────────────────────────────────┐
│ packages: 'bundle' │
│ │
│ source ──► esbuild ──► one self-contained file │
│ (if all packages are bundle-safe) │
│ │
│ packages: 'external' │
│ │
│ source ──► esbuild ──► application code only │
│ + import statements │
│ + node_modules at runtime │
└─────────────────────────────────────────────────────────────┘
The detection rule
┌─────────────────────────────────────────────────────────────┐
│ import path │
│ │ │
│ ├── starts with / . .. ──► bundle (file path) │
│ │ │
│ ├── starts with # ──► subpath import, not externalized│
│ │ │
│ ├── tsconfig paths remap ──► applied first, then file │
│ │ │
│ └── otherwise ──► package import, externalized │
└─────────────────────────────────────────────────────────────┘
Deployment
┌─────────────────────────────────────────────────────────────┐
│ BUILD HOST RUNTIME HOST │
│ │
│ esbuild node dist/server.js │
│ → dist/server.js │ │
│ → imports left intact ├── express from node_modules│
│ └── app code from bundle │
│ │
│ The bundle does not contain express. │
│ The runtime must provide it. │
└─────────────────────────────────────────────────────────────┘
The plugin
┌─────────────────────────────────────────────────────────────┐
│ esbuild-node-externals │
│ │
│ package.json │
│ dependencies: { express, sharp } │
│ devDependencies: { typescript } │
│ │ │
│ ▼ │
│ nodeExternalsPlugin() │
│ │ │
│ ▼ │
│ external: ['express', 'sharp', 'typescript', ...] │
│ │ │
│ ▼ │
│ esbuild leaves them as imports │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Option | --packages=external |
| Default (Node) | external since esbuild 0.22.0 |
| Default (Browser) | bundle |
| What is externalized | Bare package imports |
| What is not externalized | Relative, absolute, # subpath, tsconfig aliases |
| Runtime requirement | node_modules must exist |
| Alternative | external: [...] for targeted exclusion |
| Plugin | esbuild-node-externals |
| Plugin allowlist | Bundle a package.json dependency anyway |
Key takeaways:
--packages=externalis the backend build’s default, but the explicit flag documents intent. Since esbuild 0.22.0,platform: 'node'externalizes package imports by default. Settingpackages: 'external'explicitly protects against a configuration change and makes the build script readable .- The detection rule is syntactic. A bare import (
react,@scope/pkg,pkg/subpath) is a package import. A relative path (./local), an absolute path (/absolute), a subpath import (#internal), and a tsconfig remapped path (@/alias) are not. The rule applies before any semantic analysis . - The bundle is not self-contained. When packages are external, the output contains
import express from 'express'and expectsnode_modulesto be present at runtime. The deployment must install production dependencies alongside the bundle. --packages=externaland--external:solve different problems. The former externalizes everything that looks like a package; the latter externalizes named packages. A project with a few native modules can useexternal: ['sharp', 'canvas']and bundle the rest. A backend build that relies onnode_modulesat runtime usespackages: 'external'.- The
esbuild-node-externalsplugin externalizes exactly whatpackage.jsondeclares. It readsdependencies,devDependencies,peerDependencies, andoptionalDependencies, and excludes them from the bundle. TheallowListoption bundles a package despite its presence inpackage.json. - The deployment must install the externalized dependencies. The bundle plus
package.jsonplusnpm ci --omit=devis the standard pattern. The bundle is small, and the dependencies are resolved by Node at runtime.
Remember: --packages=external is not a way to make a bundle smaller. It is a way to leave the dependency resolution to Node, which is the correct model for a backend build where the deployment environment installs dependencies from package.json. The bundle contains the application code. The dependencies are external. The deployment must provide them. The detection rule is syntactic: a bare import is a package, a relative path is a file. The subpath import and tsconfig alias exceptions exist because those are not package imports in the Node resolution model. Set the flag explicitly, deploy the node_modules, and use the plugin when the externalization should follow package.json rather than the import syntax.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!