Building Single-Executable Node.js Apps (Without the Pain)

Published: (December 31, 2025 at 09:00 AM EST)
5 min read
Source: Dev.to

Source: Dev.to

Single Executable Applications (SEA)

Node.js was never meant to ship the way it does today.
By the end of this article you’ll have a running command‑line executable – no node install, no npm install, no “works on my machine”. Just a binary.

And not only a CLI, but a real GUI executable, shipped the same way.

Node.js is an incredible C++ runtime when you command it properly.
Unfortunately the tooling story, plus the endless CommonJS ↔ ESM compatibility mess, can be a real pain.

In the spirit of bringing better tooling to Node.js, inspired by the most beautiful bundler I’ve ever seen – the Zig bundler – I decided to lean into Node’s most under‑used feature:

Quick Start

// build.js
import { release } from "sea-builder";

const production = {
  entry: "app/index.js",
  name: "myapp",
  platforms: ["linux-x64", "win-x64"],
  outDir: "./dist",
  assets: {
    "README.md": "./README.md",
  },
};

release(production)
  .then((res) => console.log(res))
  .catch((err) => console.log(err));

That’s it – you get an executable that contains:

  • Your app
  • The Node runtime
  • All assets

Benefits

  • Fast startup (especially with code cache)
  • Low‑level utilities like snapshots
  • Predictable native modules
  • A user experience that matches real software, not a dev environment

This is how Go, Rust, and Zig ship. Node can do it too – it just never had good tooling. That’s what sea‑builder is.

Note: Requires Node.js 20+. Check your version with node -v.

Install

npm init -y && npm i sea-builder

Project layout

app/
  index.js
  README.md
cross-build.js

Example: Simple CLI

// app/index.js
const { getAsset, isSea } = require("node:sea");

console.log(`Hello, ${process.argv[2] || "World"}!`);
console.log(`Running from: ${process.execPath}`);
console.log(`Platform: ${process.platform}`);
console.log("Running as SEA!\n");

const readme = getAsset("README.md", "utf8");
console.log("readme:", readme);

// binary asset (returns ArrayBuffer)
const configBinary = getAsset("README.md");
console.log(
  "Binary size:",
  configBinary.byteLength,
  "bytes"
);
// cross-build.js
const { release } = require("sea-builder");

const production = {
  entry: "app/index.js",
  name: "myapp",
  platforms: ["linux-x64", "win-x64"],
  outDir: "./dist",
  assets: {
    "README.md": "./README.md",
  },
};

release(production)
  .then((res) => console.log(res))
  .catch((err) => console.log(err));

Run the build:

node cross-build.js

sea-builder downloads the correct Node binary and patches it. Easy, right?

Example: Windowed (GUI) App – tessera.js

tessera.js is a Node.js renderer I built.
Read more about it in the article [How I Built a Graphics Renderer for Node.js].
For this guide we’ll use a prepared repository.

git clone https://github.com/sklyt/tessera-seaexample.git
cd tessera-seaexample
npm i
node ./tesseraexample/build.js

The resulting executable will be at ./dist/img.exe.

Why bundle to CommonJS first?

ESM is the enemy of everything good, including SEA.
We first bundle everything to CommonJS, then hand the bundle to sea-builder.

// inside build.js (esbuild step)
await build({
  entryPoints: [entryJs],
  bundle: true,
  platform: "node",
  target: "node22",          // adjust to your minimum Node target
  format: "cjs",
  outfile: bundlePath,

  // Native binaries stay external
  external: ["*.node", "*.dll"],

  sourcemap: false,
  logLevel: "info",

  define: {
    require: "require",
  },
});

SEA configuration for the GUI app

const bundlerOptions = {
  entry: bundlePath,               // bundled script
  name: appName,
  platforms: targets,
  outDir: outDir,
  assets: {
    "win-x64": {
      "prebuilds/win32-x64/renderer.node": join(
        __dirname,
        "/prebuilds/win32-x64/renderer.node"
      ),
      "prebuilds/win32-x64/glfw3.dll": join(
        __dirname,
        "/prebuilds/win32-x64/glfw3.dll"
      ),
      "prebuilds/win32-x64/raylib.dll": join(
        __dirname,
        "/prebuilds/win32-x64/raylib.dll"
      ),
    },
    "linux-x64": {
      "prebuilds/linux-x64/renderer.node": join(
        __dirname,
        "/prebuilds/linux-x64/renderer.node"
      ),
    },
  },

  // Optional flags (code cache works only on the current platform;
  // use CI workflows for cross‑compile builds)
  useCodeCache: false,
  useSnapshot: false,
};

console.log("Calling sea() with bundled entry and platform assets...");
await sea(bundlerOptions);
console.log("sea() finished. Outputs are in", path.resolve(outDir));

Loading assets from an SEA

// img.js (helper)
const getter = (name) => {
  try {
    return getAsset(name); // string or ArrayBuffer
  } catch {
    return null;
  }
};

const { Renderer, FULLSCREEN, RESIZABLE } = loadRendererSea({
  assetGetterSync: getter,
});

That’s it – you now have a shareable GUI executable.

Future note: In a forthcoming version of sea-builder the terminal won’t launch alongside GUI executables. If needed, you can patch the binary headers directly. But honestly, it already works.

Example sea-builder Configurations

module.exports = {
  // Development build – fast iteration
  dev: {
    entry: "src/index.js",
    name: "myapp-dev",
    platforms: "current",
    outDir: "./build",
    useCodeCache: true,
  },

  // Production build – all platforms
  production: {
    entry: "src/index.js",
    name: "myapp",
    platforms: "all",
    outDir: "./dist",
    clean: true,
    assets: {
      "config.json": "./config/production.json",
      "README.md": "./README.md",
      LICENSE: "./LICENSE",
    },
  },

  // Server build – Linux only
  server: {
    entry: "src/server.js",
    name: "myapp-server",
    platforms: ["linux-x64", "linux-arm64"],
    outDir: "./dist/server",
    useCodeCache: true,
    assets: {
      "config.json": "./config/server.json",
    },
  },

  // CLI tool – small and portable
  cli: {
    entry: "src/cli.js",
    name: "myapp-cli",
    platforms: ["linux-x64", "win-x64", "macos-arm64"],
    outDir: "./dist/cli",
    useCodeCache: true,
    assets: {
      "help.txt": "./assets/help.txt",
    },
  },
};

Recap

  • SEA lets you ship a single binary containing your Node app, the runtime, and any assets.
  • sea-builder automates downloading the correct Node binary, patching it, and bundling your code (preferably as CommonJS).
  • The result feels like native Go/Rust/Zig binaries – fast startup, predictable native modules, and a clean user experience.

Give it a try and enjoy the simplicity of “write once, run everywhere” with Node.js!

Build Configuration

{
  // …
  cli: {
    entry: './src/cli.js',
    platforms: 'all',
    outDir: './dist/cli'
  }
}

Note: useCodeCache: true only works on the current platform since it’s platform‑specific. Use CI to build cached binaries per target.

Build Script (build.js)

// build.js
const { sea } = require('sea-builder');
const config = require('./sea.config');

// node build.js production
const profile = process.argv[2] || 'dev';

if (!config[profile]) {
  console.error(`Unknown profile: ${profile}`);
  console.log('Available profiles:', Object.keys(config).join(', '));
  process.exit(1);
}

console.log(`Building with profile: ${profile}`);

sea(config[profile]).then(results => {
  console.log(`\nBuilt ${results.length} executable(s)`);
});

More from me

  • Visualizing Evolutionary Algorithms in Node.js
  • tessera.js repo

Thanks for reading!

Find me here

Back to Blog

Related posts

Read more »