Bundling for Browser and Node with the platform Option

A library that runs in both the browser and Node cannot use one bundle — the two environments differ on module format, which package.json exports conditions apply, and whether node: built-ins are available. esbuild’s platform option flips all of those defaults at once. This guide produces both bundles from one config. It builds on the esbuild API and CLI for rapid builds; the parent overview is esbuild & Turbopack Workflows.

The problem is not academic. The same npm package is increasingly expected to work in a bundler-built browser app, in a Node server, in an edge runtime, and in a test runner that shims one of those. Each of those consumers resolves your package differently. A browser bundler reads the browser condition and expects no node:fs; Node reads the node condition and expects CommonJS require or native ESM; an edge runtime wants neither Node built-ins nor DOM globals. If you ship one bundle built with the wrong assumptions, the failure mode depends on which consumer hits it first — a browser build that inlined a Node built-in throws Dynamic require of "fs" is not supported at runtime, and a Node build that assumed browser globals throws ReferenceError: window is not defined. Neither error points back at the build config that caused it, which is why this class of bug eats an afternoon.

platform sits at the resolution stage of the build, before any code is transformed or emitted. It does not rewrite your source; it changes the questions esbuild asks the resolver about every bare import. Because it decides which files even enter the graph, getting it wrong cascades — a single mis-resolved dependency can pull an entire subtree of the wrong environment’s code into the output. Treat platform as the first decision of a build, not a flag you tune at the end. Everything downstream — format, externals, tree-shaking of environment-guarded branches — assumes it is already correct.

The rest of this guide walks the full pipeline: what each platform value presets, how to diagnose which code belongs to which target, an annotated two-build config, verification that each bundle runs where it should, and the edge cases that produce the two runtime errors above.

One source, two platform targets with different defaults The same source builds twice: platform browser resolves the browser exports condition, defaults to no Node built-ins, and prefers ESM, while platform node keeps Node built-ins external and defaults to CommonJS, producing two bundles for the two runtimes. platform flips resolution and defaults together src/index.tsone source platform: 'browser'browser exports · ESM platform: 'node'node: external · CJS
Figure: platform is a single switch that changes resolution conditions, default format, and built-in handling at once.

Prerequisites & reproducible setup

# esbuild 0.25.x, Node 18+
mkdir platform-demo && cd platform-demo && npm init -y
npm install --save-dev esbuild@0.25.0
cat > src/index.ts <<'TS'
export const now = () => Date.now();
// A conditional Node-only path, guarded so the browser build tree-shakes it.
export async function readOnNode(path: string) {
  const { readFile } = await import('node:fs/promises');
  return readFile(path, 'utf-8');
}
TS

The source above is deliberately mixed: now() is portable, and readOnNode() reaches into node:fs/promises through a dynamic import(). That dynamic import is the seam. A static import { readFile } from 'node:fs/promises' at the top of the file would be pulled into the graph unconditionally, and the browser build would have to externalize or shim it. A dynamic import inside an async function that the browser never calls is reachable code as far as the parser is concerned, but esbuild can leave the specifier external so the reference costs nothing at load time. This is the pattern you want for any “works everywhere, does more on Node” module: keep the environment-specific call behind a dynamic import so the other platform’s build can prune or externalize it cleanly.

platform takes browser (default), node, or neutral. It sets the default format, the conditions/mainFields used to resolve packages, and whether node: built-ins are treated as external.

Concretely, each value expands to a bundle of defaults. platform: 'browser' sets format to iife unless you also pass bundle: true and a format, applies the resolution conditions ["browser", "module", ...], reads the browser field and browser sub-map in package.json, and refuses to treat node: built-ins as automatically external — a Node built-in in a browser build is an error you must resolve. platform: 'node' sets format to cjs, applies ["node", "require", ...] conditions, reads main/module in the Node order, and marks the full set of node: built-ins (fs, path, crypto, and the rest) external because the runtime supplies them. platform: 'neutral' clears all of that: no implied format, no conditions, no mainFields, and no built-in handling. Neutral is the honest choice for a runtime that is neither Node nor a browser — a serverless edge worker, a game engine’s script host — but it hands you the full configuration burden, which is why it is a common source of confusing “nothing resolves” builds when reached for by accident.

Three platform values and what each assumes platform browser defaults to ESM, browser conditions and no Node built-ins; platform node defaults to CommonJS, node conditions and external built-ins; and platform neutral makes no assumptions, leaving format and conditions to you. browserESM · browser cond nodeCJS · node cond · external neutralno assumptions one option, a bundle of defaults
Figure: each platform value is really a preset of format, conditions, and built-in handling.

Diagnosis workflow

Before you write any build config, inventory what in your source is environment-bound. The goal of this pass is to end with a clear map of which imports and globals belong to which target, because every later decision — format, externals, conditions — falls out of that map.

  1. Identify environment-specific code. A node: import or a browser-only global (window) marks code that must be excluded from the other build. Grep the source for node:, for the bare Node built-in names (fs, path, os, crypto, child_process), and for DOM globals (window, document, navigator, localStorage). Each hit is a fork in the graph: it either needs to be guarded behind a dynamic import so the other platform can prune it, or the module genuinely cannot serve both targets and needs to split into two entry points. The cost of skipping this step is discovering the split at runtime, in the consumer’s environment, where the stack trace names their code and not yours.
  2. Pick format per target. The browser bundle is usually ESM; the Node bundle CJS or ESM depending on how consumers load it — match the package’s exports. The tie-breaker is who loads the file. A browser bundle consumed by another bundler (Vite, webpack, Rollup) should be ESM so the downstream bundler can tree-shake it; there is no reason to ship IIFE unless you are targeting a <script> tag directly. On the Node side, CJS still resolves from both require() and native ESM import via interop, so cjs is the safe default for a library that cannot control how it is loaded; choose esm only when you know consumers are ESM and you want top-level await or want to avoid the interop shim.
  3. Decide externals. For Node, keep node: built-ins and probably dependencies external; for the browser, bundle everything the page needs. The principle is “externalize what the runtime resolves for you.” Node resolves node_modules at runtime, so bundling dependencies into a Node library only duplicates code the consumer already has and defeats their deduplication. The browser has no node_modules at runtime, so anything not bundled must be provided by the page’s own bundler — which means for a library you externalize peer dependencies (React, for instance) and bundle the rest, and for a standalone browser artifact you bundle everything.
External for Node, bundled for the browser A Node build typically keeps node built-ins and dependencies external because the runtime provides them, while a browser build bundles everything the page needs since there is no package resolution at runtime. node buildnode: + deps externalruntime provides them browser buildbundle everythingno runtime resolution
Figure: externals invert between targets — Node externalizes, the browser bundles.

How it works under the hood

When esbuild resolves a bare import like import x from 'some-pkg', it walks that package’s package.json looking for an exports map, and inside that map it matches condition keys in the order the author wrote them, keeping the first key whose name is in the active condition set. platform is what seeds that active set. With platform: 'browser', the set contains browser and import/module; with platform: 'node', it contains node and require. This is why the same import 'some-pkg' can pull two entirely different files into two builds without any change to your source — the resolver is answering a different question each time. If a package’s exports lists node before default, the Node build takes the node entry and the browser build falls through to default; reverse the order and you can accidentally serve the Node file to the browser.

Built-in handling happens at the same stage but through a different mechanism. For platform: 'node', esbuild carries an internal list of Node’s built-in module names and marks any import that matches — with or without the node: prefix — as external, emitting a require('fs') or a preserved import rather than trying to find a file. For platform: 'browser' there is no such list, so a node:fs import is treated as an ordinary specifier that must resolve to a real file; when it cannot, you get a build error. That asymmetry is deliberate: the browser genuinely has no fs, so esbuild refuses to pretend it does rather than emit a bundle that throws at load.

Format interacts with tree-shaking in a way worth internalizing. esbuild only tree-shakes when bundle: true, and dead-code elimination across a dynamic import() boundary depends on whether the branch is statically reachable. In the sample module, readOnNode is exported, so its body is retained in both builds — esbuild cannot prove the consumer never calls it. What differs is the node:fs/promises specifier inside it: in the Node build it stays as a live external import the runtime satisfies, and in the browser build we externalize it so it survives as a string that is only evaluated if the function is ever called, which in a browser it should not be. The lesson is that “environment-specific” code is not automatically removed by picking a platform; you remove it by structuring reachability and externals, and platform only sets the defaults those decisions start from.

Annotated solution config

The config below runs two esbuild.build() calls that share an entryPoints/bundle/sourcemap base and diverge only where the target demands it. Reading it top to bottom: the shared object fixes the source and turns bundling and source maps on for both; the browser call sets platform: 'browser' and pins format: 'esm' (never rely on the platform default here — pin it, so a future esbuild default change cannot silently flip your output format); the Node call sets platform: 'node', format: 'cjs', and target: 'node18' so downlevel transforms match the oldest Node you support. The two external strategies are the crux and are covered in the comments inline.

// build.mjs — esbuild 0.25.x, Node 18+, two platform builds from one source.
import * as esbuild from 'esbuild';

const shared = { entryPoints: ['src/index.ts'], bundle: true, sourcemap: true };

// Browser: ESM, no Node built-ins, tree-shake the node-only branch.
await esbuild.build({
  ...shared,
  platform: 'browser',
  format: 'esm',
  outfile: 'dist/index.browser.js',
  // The dynamic import('node:fs/promises') is unreachable in browser use;
  // mark node built-ins external so they do not error if referenced.
  external: ['node:*'],
});

// Node: CJS (or ESM), keep built-ins and deps external.
await esbuild.build({
  ...shared,
  platform: 'node',
  format: 'cjs',
  outfile: 'dist/index.node.cjs',
  packages: 'external',   // keep dependencies external in Node
  target: 'node18',
});
// package.json — route each runtime to its bundle via conditions.
{
  "exports": {
    ".": {
      "browser": "./dist/index.browser.js",
      "node": "./dist/index.node.cjs",
      "default": "./dist/index.browser.js"
    }
  }
}

Two details in that exports map are load-bearing. The browser condition must appear before node only if you also want bundlers that set both to prefer the browser build — most set only browser in a browser context, so ordering rarely bites here, but it is worth being deliberate rather than lucky. The default at the end is the catch-all for any resolver that matches neither named condition — a test runner, an older tool, an edge runtime that sets neither browser nor node. Pointing default at the browser build is the conservative choice because the browser build has the fewest environmental assumptions baked in; if that build still trips on a missing DOM global, the caller sees it immediately rather than getting a Node bundle that fails more obscurely.

If your consumers are ESM-first and you would rather ship a native ESM Node build than CJS, add a third build and split the Node condition into import/require sub-conditions so each loader gets a matching format:

// build.node-esm.mjs — esbuild 0.25.x, Node 18+, a native-ESM Node bundle.
import * as esbuild from 'esbuild';

await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  sourcemap: true,
  platform: 'node',
  format: 'esm',                 // native ESM output for `import` consumers
  outfile: 'dist/index.node.mjs',
  packages: 'external',
  target: 'node18',
  // Shim __dirname/__filename which do not exist in ESM, if your code uses them.
  banner: { js: "import{fileURLToPath}from'node:url';import{dirname}from'node:path';const __filename=fileURLToPath(import.meta.url);const __dirname=dirname(__filename);" },
});
// package.json — dual Node format: import gets ESM, require gets CJS.
{
  "exports": {
    ".": {
      "browser": "./dist/index.browser.js",
      "node": {
        "import": "./dist/index.node.mjs",
        "require": "./dist/index.node.cjs"
      },
      "default": "./dist/index.browser.js"
    }
  }
}

Shipping both Node formats is the fully general answer, but it re-introduces the dual-package hazard — two copies of your module can coexist in one process if some deps require you and others import you. Only reach for the dual-format Node build when you have measured a real need; a single cjs Node build interops from both loaders and sidesteps the hazard entirely, which is why the primary config above ships one Node file.

Two builds, then exports routes each runtime The script produces a browser ESM bundle with node built-ins external and a node CJS bundle with packages external, and the package.json exports map routes the browser condition to the first and the node condition to the second. two buildsbrowser ESM · node CJSdifferent externals exports conditionsbrowser → node →default build both, then let the consumer pick
Figure: the two builds are meaningless without the exports conditions that route each runtime to the right one.

Verification

# esbuild 0.25.x — build both, then run each in its runtime.
node build.mjs
# Node bundle runs under Node:
node -e "console.log(typeof require('./dist/index.node.cjs').readOnNode)"  # 'function'
# Browser bundle has no node: import in its output:
grep -c 'node:fs' dist/index.browser.js    # expect 0 — externalized/pruned

The Node bundle runs under Node with node:fs available, and the browser bundle has no node: reference in its output. Loading the package from a bundler resolves the browser or node condition automatically, so each environment gets the build that runs there.

Each check maps to a specific failure it rules out. Running the Node bundle with require('./dist/index.node.cjs').readOnNode and asserting 'function' confirms two things at once: the CJS output is loadable under Node’s require path, and the Node-only export survived the build with its node:fs dependency intact. If this printed 'undefined', the export was dropped — usually a sign the entry point or exports mapping is wrong, not the platform. The grep -c 'node:fs' dist/index.browser.js returning 0 proves the browser bundle carries no live reference to a Node built-in; a non-zero count means either the external: ['node:*'] did not match (check the glob) or the specifier was written in a form the externalization missed, such as an unprefixed require('fs') that some transitive dependency emitted.

For a stronger guarantee than grep, load the browser bundle in an environment with no node: support and confirm it does not throw on import. A quick way is to import it under a bundler that targets the browser, or to evaluate it in a --experimental-vm-modules context without Node built-ins exposed. In CI this is worth doing because grep only catches the literal string; it will not catch a bundle that resolved a Node polyfill and now silently ships a browser-incompatible shim. The rule is: grep is a fast smoke test, actually executing the bundle in a hostile-to-the-other-platform context is the real proof.

Node bundle runs, browser bundle has no node: import Verification runs the Node bundle under Node to confirm the node-only function works, and greps the browser bundle to confirm it contains no node: import, showing each build suits its runtime. node bundle: runsnode:fs available browser bundle: 0 node:clean each bundle proven in its own runtime
Figure: run one in Node and grep the other — each proves it belongs to its runtime.

Gotchas & edge cases

Four platform-targeting edge cases platform neutral makes no assumptions so format and conditions must be set explicitly; a browser build referencing a node built-in errors unless externalized or guarded; packages external in Node means consumers must install those deps; and an exports order that omits default can leave some resolvers unmatched. neutral needs config→ set format + conditions node built-in in browser→ externalize or guard packages external→ consumers install deps missing default condition→ add a default fallback
Figure: platform: 'neutral' is the trap — it assumes nothing, so you must set format and conditions yourself.
  • neutral assumes nothing. With platform: 'neutral' you must set format, conditions, and mainFields yourself; it is for edge runtimes, not a default. The symptom of reaching for it by accident is a build where bare imports fail to resolve or resolve to the wrong file, because with no conditions seeded the resolver falls back to main/module only and ignores every exports condition key. The fix is to pass an explicit conditions: ['worker', 'browser'] (or whatever your runtime advertises) and a chosen format. Confirm by building a tiny module that imports a condition-mapped dependency and checking the resolved path in --metafile output — if it resolved through the condition you set, neutral is configured correctly.
  • Browser + node built-in. A browser build that reaches a node: import errors unless it is externalized or behind an unreachable guard. The symptom is a build-time error naming the built-in (Could not resolve "node:fs"), not a runtime one, which is good — esbuild caught it. The root cause is a static import of a Node module in code the browser build still includes. The fix is either to move the call behind a dynamic import() inside a function the browser never calls (so it can be externalized), or to genuinely split the module. Confirm with the same grep -c 'node:' on the output; a clean browser bundle has zero live references. Reaching for a Node polyfill instead is almost always the wrong fix — it ships kilobytes of shim to emulate a filesystem the browser does not have.
  • packages: 'external' shifts install cost. Externalizing deps in the Node build means consumers must have them installed; fine for a library, not for a CLI you want self-contained. The symptom of getting this wrong for a CLI is Cannot find module at the user’s machine because the dependency was never bundled and never installed alongside your binary. The rule of thumb: packages: 'external' for libraries that live in a consumer’s node_modules (they already have your deps deduplicated), but bundle everything for a standalone CLI or a Docker image where you control the whole tree. Confirm by deleting node_modules and running the artifact — a self-contained build still runs, an externalized one fails to resolve.
  • Provide a default condition. Add a default in exports so resolvers that match neither browser nor node still get a bundle. The symptom of omitting it is an opaque ERR_PACKAGE_PATH_NOT_EXPORTED from a consumer whose environment set neither condition — a test runner, a bundler with a nonstandard condition set, an edge runtime. Because exports is exhaustive when present, a missing branch is not a soft fallback to main; it is a hard failure. The default key, listed last so named conditions still win, closes that gap. Confirm by resolving the package with a condition set that matches nothing named (node --conditions=nothing -e "require.resolve('your-pkg')") and checking it lands on the default file rather than throwing.

Performance considerations

Two esbuild.build() calls are two full graph traversals, but esbuild is fast enough that this is rarely the bottleneck for a single package — both builds together typically finish in tens of milliseconds. Where it matters is a workspace with dozens of packages each building twice: there the win is running the two calls concurrently with Promise.all rather than the sequential await shown for readability, and reusing esbuild’s built-in incremental context (esbuild.context() with rebuild()) so watch-mode rebuilds only re-traverse changed files. Do not reach for a plugin to share the graph between the browser and Node builds — the graphs genuinely differ because resolution differs, so a shared graph would be wrong, and the duplicated traversal is cheap.

The larger performance lever is what each build includes. The browser build’s size is dominated by whatever you chose to bundle rather than externalize; auditing it with --metafile and a bundle analyzer catches an accidentally-inlined dependency that should have been a peer. The Node build’s size is nearly irrelevant when packages: 'external', since it emits mostly your own code, which is the point — a Node library should be a thin wrapper the consumer’s own install fills in.

CI integration

Wire both builds and both verification checks into CI so a change that breaks one platform fails the pipeline rather than a consumer’s install. A minimal job runs node build.mjs, then the two verification commands from above as assertions — a non-'function' result or a non-zero grep count should exit non-zero. Add a size budget on the browser bundle (esbuild --metafile plus a small script that fails when the output exceeds a threshold) so an accidental dependency inline is caught as a regression, not discovered by a user on a slow connection. Because esbuild is deterministic given a locked esbuild version, pin it in devDependencies and the CI artifact is byte-reproducible, which makes the size assertion trustworthy across runs.