Single Executable Applications
Node.js 20 (stable in 21.7) supports Single Executable Applications (SEA) — packaging your JavaScript application and the Node.js runtime into a single self-contained binary. The binary can be distributed and run on a target machine without Node.js installed. This is useful for CLI tools, desktop utilities, and any application where you want zero-dependency distribution. The approach differs from bundlers (esbuild, webpack) which bundle your JS but still require Node to run; SEA embeds Node itself.
The build process
Build steps for a SEA binary:
1. Bundle your JS into a single file (esbuild/rollup)
└─ src/cli.ts → dist/cli.js (one file, no node_modules dependency)
2. Create a SEA config JSON:
└─ { "main": "dist/cli.js", "output": "sea-prep.blob" }
3. Generate the blob:
└─ node --experimental-sea-config sea-config.json
└─ sea-prep.blob (your app, serialized for injection)
4. Copy the node binary:
└─ cp $(node -e "process.execPath") my-cli
5. Inject the blob into the binary:
└─ npx postject my-cli NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
6. (macOS) Sign the binary:
└─ codesign --sign - my-cliStep-by-step with a real CLI
# 1. Bundle your app into a single JS file with esbuild:
npx esbuild src/cli.ts --bundle --platform=node --outfile=dist/cli.js
# 2. Create sea-config.json:
cat > sea-config.json << 'EOF'
{
"main": "dist/cli.js",
"output": "sea-prep.blob",
"disableExperimentalSEAWarning": true
}
EOF
# 3. Generate the blob:
node --experimental-sea-config sea-config.json
# 4. Copy the Node binary:
cp $(node -e "process.execPath") my-cli
# On Windows: cp (node -e "process.execPath") my-cli.exe
# 5. Remove existing signature (macOS only):
codesign --remove-signature my-cli
# 6. Inject the blob:
npx postject my-cli NODE_SEA_BLOB sea-prep.blob \
--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
--macho-segment-name NODE_SEA # macOS only
# 7. Re-sign (macOS only):
codesign --sign - my-cli
# 8. Run:
./my-cli --help(node:12345) ExperimentalWarning: Single executable application is an experimental feature... Usage: my-cli [options]
sea-config.json options
{
"main": "dist/cli.js",
"output": "sea-prep.blob",
"disableExperimentalSEAWarning": true,
"useSnapshot": false,
"useCodeCache": true,
"assets": {
"logo.png": "./assets/logo.png",
"config.json": "./default-config.json"
}
}// Accessing bundled assets from inside the SEA:
import { getAsset, getAssetAsBlob, getRawAsset } from 'node:sea'
import { isSea } from 'node:sea'
if (isSea()) {
// Running as a SEA binary
const configBuffer = getRawAsset('config.json') // ArrayBuffer
const config = JSON.parse(Buffer.from(configBuffer).toString('utf8'))
} else {
// Running as a regular Node process (development)
const config = JSON.parse(fs.readFileSync('./default-config.json', 'utf8'))
}SEA vs third-party packaging tools
Tool | Approach | Pros | Cons |
|---|---|---|---|
Node SEA (built-in) | Inject JS blob into a copy of the Node binary | No extra tooling, official support | Experimental; binary is ~90MB (includes full Node) |
pkg (Vercel) | Embed Node + app into custom executable | Mature, wide adoption | Unmaintained (archived by Vercel 2023) |
nexe | Similar to pkg; patches Node binary | Active community | Complex build; compiler required |
Bun | Bundle + embed Bun runtime | Smallest binary, fast | Different runtime (Bun, not Node); compatibility considerations |
Cross-platform build matrix
# Build for multiple platforms in CI (GitHub Actions example): # strategy: # matrix: # os: [ubuntu-latest, macos-latest, windows-latest] # # Each job: # 1. checkout + npm ci # 2. esbuild bundle # 3. node --experimental-sea-config sea-config.json # 4. Platform-specific inject + sign steps # 5. Upload artifact # The resulting binaries: # my-cli-linux-x64 (no extension) # my-cli-macos-x64 (codesigned) # my-cli-win-x64.exe