How would you structure JS for browser, Node, and edge?
JavaScript Developer
answer
Create a universal core and thin environment adapters. Publish dual modules with package.json#exports (conditional import/require, browser, worker), mark "type": "module" and ship a .cjs build for CommonJS. Keep shared logic ESM-only for tree-shaking; expose side-effect-free entry points and set "sideEffects": false. Access platform APIs via injected ports (fetch, crypto, storage) with small shims for Node and edge. For SSR, avoid global checks in core; hydrate with the same data model to prevent mismatches.
Long Answer
A durable shared JavaScript architecture treats runtime differences as infrastructure, not as business logic. The goal is one body of core code reused across browser, Node.js, and edge workers, with precise packaging so bundlers tree-shake effectively and servers render without hydration glitches.
1) Project layout and boundaries
Split code by responsibility, not by runtime:
/src
/core // pure, runtime-agnostic logic (domain, formatting, parsers)
/ports // TypeScript interfaces for env capabilities (Http, Crypto, KV, Cache)
/adapters
/browser // implements ports with Web APIs (fetch, crypto.subtle, CacheStorage)
/node // implements ports with Node (fetch/undici, Web Crypto, fs if needed)
/edge // implements ports with edge-safe APIs (Request/Response, Web Crypto)
/ssr // universal rendering helpers; no window/document assumptions
/index.ts // re-export curated APIs
Core never imports from adapters. Apps compose core with an adapter at the boundary (dependency inversion), so there is zero if (isNode) branching inside business logic.
2) Dual-module packaging (ESM and CJS)
Publish ESM-first for tree-shaking, but keep a CJS build for legacy code:
- package.json:
- "type": "module"
- "type": "module"
"exports" with conditional subpath exports:
{
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"browser": "./dist/browser.mjs",
"worker": "./dist/edge.mjs",
"default": "./dist/index.mjs"
}
}
- Avoid the old "module"/"main" ambiguity; rely on exports.
- Emit *.mjs (ESM) and *.cjs (CJS). Generate .d.ts once from the source types.
- Use tsconfig with moduleResolution: nodenext so TypeScript understands dual builds.
3) Tree-shaking and side-effect hygiene
Tree-shaking only works if modules are ESM and side effects are explicit:
- Keep modules single-purpose; avoid barrel files that re-export everything.
- Mark "sideEffects": false in package.json (or per-file globs) and avoid implicit side effects (polyfills, global patches) in published entry points.
- Prefer functions and immutable utilities over class singletons. Annotate pure helpers with /* @__PURE__ */ when creating factory returns in build tools that honor it.
- Keep environment detection out of core, so bundlers can dead-code-eliminate unused adapters.
4) Environment shims and ports/adapters
Standardize on WHATWG APIs:
- HTTP: depend on the fetch signature. In Node, use a fetch-compatible client (Node 18+ has global fetch). In edge and browser, use native fetch. Provide a tiny wrapper that normalizes timeout/abort and headers casing.
- Crypto: use crypto.subtle (Web Crypto). Node crypto.webcrypto.subtle and edge runtimes match the spec; inject via adapter.
- Storage/Cache: design port interfaces (KV, Cache) and back them with localStorage or IndexedDB in browser, Redis or memory in Node, and KV/Cache APIs at the edge.
- Streams: stick to Web Streams (ReadableStream), which exist in edge, browsers, and modern Node. Adapt legacy Node streams in the Node adapter.
By coding against ports, the universal layer never references window, process, Buffer, or global.
5) Conditional exports for runtime specialization
Provide environment-tuned bundles without duplicating logic:
- A default universal build under "import".
- A "browser" condition that swaps tiny adapter imports (e.g., no Node polyfills).
- A "worker" (or "edge") condition to exclude Node-only code paths and ensure edge-safe dependencies.
- Keep the implementation identical; only the adapter bindings differ. This keeps the test matrix small.
6) SSR safety and hydration parity
SSR pitfalls arise when server and client diverge:
- Do not read globals in core. If UI code needs window.innerWidth, pass it as a parameter or compute on client after mount.
- Use a single data model for SSR and client hydration. Serialize only necessary state; embed it as a <script type="application/json" id="__INITIAL_STATE__"> and parse once.
- Deterministic rendering: avoid time-dependent output unless you inject a clock port; seed it consistently on server/client to prevent flicker.
- Prefer Web Streams for SSR responses; stream the shell first, then data islands. The core rendering helpers must be pure functions of (props, env).
7) Build tooling and outputs
Use a single config to emit all targets:
- Choose Rollup/esbuild/tsup to produce:
- dist/index.mjs (ESM) with platform: neutral.
- dist/index.cjs (CJS) for Node consumers.
- dist/browser.mjs with platform: browser to drop Node shims.
- dist/edge.mjs with platform: neutral and ensure no Node built-ins.
- dist/index.mjs (ESM) with platform: neutral.
- Externalize peer deps to keep bundles slim in app builds.
- Generate source maps and a size budget report; enforce per-chunk budgets in CI.
8) Dependency rules
- For shared code: depend on spec-level APIs (URL, TextEncoder/Decoder, fetch, Web Crypto).
- For Node-only needs (fs, path), isolate in adapters/node and never leak types into core.
- Avoid heavy universal polyfills (e.g., full Buffer); use Uint8Array and TextEncoder.
9) Testing across runtimes
- Unit-test src/core in a neutral runner (Vitest/Jest with environment: node but without Node-only APIs).
- Adapter tests:
- Browser: run in a real browser or jsdom for DOM-only concerns.
- Edge: use a worker-like env emulator (or minimal runtime) to ensure no Node imports.
- Node: regular tests with Node 18+ to leverage fetch and Web Crypto.
- Browser: run in a real browser or jsdom for DOM-only concerns.
- Contract tests ensure that each adapter satisfies the same ports interfaces.
10) Linting, types, and CI
- ESLint configs per folder: strict no-restricted-imports to prevent core from importing adapters.
- TypeScript project references: core has no dom lib; adapters opt-in to dom or node.
- CI matrix runs build + tests for Node (LTS, latest), edge emulator, and browser.
11) Runtime feature detection vs build-time flags
Prefer conditional exports to build-time dead code. If you must switch at runtime (library mode), use feature detection that is standards-based (e.g., 'crypto' in globalThis && 'subtle' in crypto) and keep it inside adapters to preserve tree-shaking in core.
With these patterns, a single shared JavaScript codebase serves browser, Node.js, and edge, delivers excellent tree-shaking, and keeps SSR deterministic.
Table
Common Mistakes
- Shipping only CJS or mixing main/module without exports, breaking bundlers and SSR.
- Putting environment checks (window, process) inside core logic, blocking tree-shaking and causing hydration mismatches.
- Relying on Node built-ins (Buffer, fs) in shared code, which fails in edge and browsers.
- Global polyfills in entry points (e.g., patching globalThis), making side effects unshakable.
- Barrel re-exports that drag entire subtrees into bundles.
- Using DOM types in core TypeScript configs, leaking web assumptions into Node.
- Serializing giant states for SSR hydration instead of minimal, deterministic payloads.
- No conditional exports; environment switching done via brittle runtime flags only.
Sample Answers (Junior / Mid / Senior)
Junior:
“I would keep most logic in a shared folder and only call platform APIs through small wrappers. I would publish ESM and CJS builds and avoid using window or process in core code. For SSR, I would render with the same data used on the client to avoid mismatches.”
Mid:
“My shared JavaScript architecture uses ports and adapters. Core is ESM-only for tree-shaking; adapters implement fetch and crypto for browser, Node, and edge. The package uses exports with conditional entries (import, require, browser). I stream SSR HTML and embed minimal initial state to keep hydration stable.”
Senior:
“I design a universal domain core with strict import rules and WHATWG-first APIs. Packaging exposes dual modules with conditional exports and side-effect-free entry points. Adapters provide fetch/crypto/storage for Node, browser, and edge, selected by conditions. SSR helpers are pure; rendering is streamed with deterministic state. CI tests each target and enforces no env leakage.”
Evaluation Criteria
Look for a design that:
- Separates core logic from adapters via ports.
- Publishes with package.json#exports for dual ESM/CJS and runtime conditions.
- Enables tree-shaking with ESM-only core and "sideEffects": false.
- Uses standards-first shims (fetch, Web Crypto, Web Streams) instead of Node-only APIs.
- Keeps SSR deterministic with pure helpers and minimal serialized state.
- Builds multiple targets (browser/node/edge) and tests each in CI.
Red flags: environment checks inside core, reliance on Buffer/fs in shared code, single-format packages, global polyfills, and hydration flicker due to data mismatches.
Preparation Tips
- Create a ports interface for Http/Crypto/Storage; write browser, node, and edge adapters.
- Configure exports with conditional entries and emit .mjs and .cjs.
- Set "sideEffects": false and remove any global polyfills from entry points.
- Ensure core TS config excludes dom libs; adapters opt in as needed.
- Build with esbuild or Rollup to produce node/browser/edge outputs; inspect bundles.
- Write SSR helper that renders to Web Streams and serializes minimal state; verify hydration parity.
- Add ESLint rules to block adapter imports in core.
- Test in Node LTS, a browser runner, and an edge-like worker environment.
- Measure bundle size and tree-shake effectiveness by importing selective functions.
Real-world Context
A team unified three libraries—browser widgets, Node services, and edge middlewares—into one shared JavaScript codebase. They introduced a ports/adapters layer, replaced Buffer-heavy code with TextEncoder/Decoder, and standardized on fetch and Web Crypto. Packaging switched to conditional exports with ESM-first builds and CJS fallbacks; "sideEffects": false and smaller entry points cut bundle sizes significantly. SSR helpers streamed HTML and embedded a tiny JSON state, eliminating hydration flicker. CI ran tests in Node, a real browser, and a worker runtime. Result: one codebase, consistent behavior across runtimes, faster builds, and fewer “works in Node but not at the edge” incidents.
Key Takeaways
- Keep one universal core; access platform features through adapters.
- Publish dual modules with conditional exports; prefer ESM for tree-shaking.
- Use WHATWG APIs and small shims; avoid Node-only globals in shared code.
- Make SSR deterministic with pure helpers and minimal serialized state.
- Enforce boundaries with linting, types, and CI across node/browser/edge.
Practice Exercise
Scenario:
You are building a library that validates input, signs payloads, fetches remote configuration, and renders an SSR-friendly HTML snippet. It must run in browser, Node.js, and edge.
Tasks:
- Create ports for Http, Crypto, and KV. Implement browser, node, and edge adapters using fetch/Web Crypto and a pluggable key-value store.
- Write src/core/validate.ts, src/core/sign.ts, and src/ssr/render.ts that depend only on the ports and pure utilities. Ensure no window or process usage.
- Configure builds that emit dist/index.mjs, dist/index.cjs, dist/browser.mjs, and dist/edge.mjs with exports conditions.
- Mark "sideEffects": false, split exports so consumers can import individual functions, and verify tree-shaking by bundling a tiny app.
- Implement an SSR function that streams HTML and injects minimal state. Write a hydration test to confirm no mismatch.
- Add ESLint rules banning adapter imports inside core.
- Create tests that run in Node, a browser environment, and a worker-like environment; verify all adapters satisfy the ports.
- Document how to consume the package in browser, Node, and edge, and how to swap adapters in application code.
Deliverable:
A working skeleton and short runbook that demonstrate a maintainable, SSR-safe shared JavaScript architecture with dual modules, tree-shaking, and environment shims across browser, Node.js, and edge.

