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.
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.
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.
- 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 fornode:, 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. - 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 bothrequire()and native ESMimportvia interop, socjsis the safe default for a library that cannot control how it is loaded; chooseesmonly when you know consumers are ESM and you want top-levelawaitor want to avoid the interop shim. - Decide externals. For Node, keep
node:built-ins and probablydependenciesexternal; for the browser, bundle everything the page needs. The principle is “externalize what the runtime resolves for you.” Node resolvesnode_modulesat runtime, so bundling dependencies into a Node library only duplicates code the consumer already has and defeats their deduplication. The browser has nonode_modulesat 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.
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.
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.
Gotchas & edge cases
platform: 'neutral' is the trap — it assumes nothing, so you must set format and conditions yourself.neutralassumes nothing. Withplatform: 'neutral'you must setformat,conditions, andmainFieldsyourself; 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 tomain/moduleonly and ignores everyexportscondition key. The fix is to pass an explicitconditions: ['worker', 'browser'](or whatever your runtime advertises) and a chosenformat. Confirm by building a tiny module that imports a condition-mapped dependency and checking the resolved path in--metafileoutput — 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 dynamicimport()inside a function the browser never calls (so it can be externalized), or to genuinely split the module. Confirm with the samegrep -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 isCannot find moduleat 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’snode_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 deletingnode_modulesand running the artifact — a self-contained build still runs, an externalized one fails to resolve.- Provide a
defaultcondition. Add adefaultinexportsso resolvers that match neitherbrowsernornodestill get a bundle. The symptom of omitting it is an opaqueERR_PACKAGE_PATH_NOT_EXPORTEDfrom a consumer whose environment set neither condition — a test runner, a bundler with a nonstandard condition set, an edge runtime. Becauseexportsis exhaustive when present, a missing branch is not a soft fallback tomain; it is a hard failure. Thedefaultkey, 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 thedefaultfile 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.
Related
- esbuild API and CLI for rapid builds — the parent cluster on the build API.
- Integrating esbuild with framework toolchains — the edge/SSR bundling where platform matters most.
- Avoiding the dual-package hazard — the exports-condition concerns these builds share.