| | |

esbuild 07 ⚡ External Packages — –packages=external for backend builds

⏱️ Estimated read: 10 min | |

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.

ValueBehavior
externalEvery package import is left in the output
bundlePackages 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 pathExternalized by --packages=external
reactYes
@scope/pkgYes
pkg/subpathYes
./localNo
../parentNo
/absoluteNo
#internalNo (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

ValueEffectDefault platform
externalLeave all package importsnode
bundleAllow packages to be bundledbrowser

What is externalized

ImportExternalized
reactYes
@scope/pkgYes
pkg/subpathYes
./localNo
../parentNo
/absoluteNo
#subpathNo
@/alias (tsconfig)No

Related options

OptionScope
packages: 'external'All package imports
external: ['sharp']Named packages only
esbuild-node-externalspackage.json dependencies
allowList (plugin)Bundle despite package.json
forceExternalList (plugin)External despite not in package.json

Deployment consequence

Bundle typeRuntime 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

PitfallWhy It HappensFix
Cannot find package 'express' at runtimepackages: 'external' but no node_modulesInstall production dependencies in the deployment
Native module fails to loadBundled instead of externalizedAdd to external or use the plugin
Subpath import externalized unexpectedlyThe # prefix is not a package importThe rule excludes # imports; check the import path
tsconfig alias externalizedThe paths remapping is applied firstThe rule excludes remapped paths; check tsconfig.json
Bundle is hugepackages: 'bundle' inlined everythingSwitch 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

ItemValue
Option--packages=external
Default (Node)external since esbuild 0.22.0
Default (Browser)bundle
What is externalizedBare package imports
What is not externalizedRelative, absolute, # subpath, tsconfig aliases
Runtime requirementnode_modules must exist
Alternativeexternal: [...] for targeted exclusion
Pluginesbuild-node-externals
Plugin allowlistBundle a package.json dependency anyway

Key takeaways:

  • --packages=external is the backend build’s default, but the explicit flag documents intent. Since esbuild 0.22.0, platform: 'node' externalizes package imports by default. Setting packages: '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 expects node_modules to be present at runtime. The deployment must install production dependencies alongside the bundle.
  • --packages=external and --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 use external: ['sharp', 'canvas'] and bundle the rest. A backend build that relies on node_modules at runtime uses packages: 'external'.
  • The esbuild-node-externals plugin externalizes exactly what package.json declares. It reads dependencies, devDependencies, peerDependencies, and optionalDependencies, and excludes them from the bundle. The allowList option bundles a package despite its presence in package.json .
  • The deployment must install the externalized dependencies. The bundle plus package.json plus npm ci --omit=dev is 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.

Was this helpful?


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!