Universal JavaScript
Universal JavaScript
Universal (or isomorphic) JavaScript is code that runs both on the server and in the browser. This chapter covers patterns for sharing code, handling environment differences, and module bundling.
Why Universal JavaScript?
Benefits of shared code:
- No duplication: Write validation, utilities, and types once
- SEO and performance: Server-side rendering with client hydration
- Developer experience: Single language, shared mental model
- Type safety: TypeScript types work everywhere
┌─────────────────────────────────────────────────────────┐
│ Shared Code │
│ • Validation logic │
│ • Data transformations │
│ • Business rules │
│ • Type definitions │
│ • API client │
└─────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Server │ │ Browser │
│ • Node.js APIs │ │ • DOM APIs │
│ • File system │ │ • Local storage │
│ • Database │ │ • User events │
│ • SSR │ │ • Hydration │
└─────────────────────┘ └─────────────────────┘Module Systems
CommonJS vs ESM
| Feature | CommonJS | ESM |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous | Asynchronous |
| Execution | Runtime | Static analysis + runtime |
| Tree shaking | No | Yes |
| Default in | Node.js (legacy) | Browsers, modern Node.js |
Dual Package Pattern
Support both CommonJS and ESM consumers:
// package.json
{
"name": "my-library",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"types": "./dist/types/index.d.ts"
}
},
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts"
}Build configuration:
// tsconfig.esm.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"outDir": "./dist/esm"
}
}
// tsconfig.cjs.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "./dist/cjs"
}
}Note
For new projects, prefer ESM-only. Dual package support adds complexity and can cause "dual package hazard" where the same module is loaded twice.
Environment Detection
Detecting the Runtime
// Check for browser
const isBrowser =
typeof window !== "undefined" && typeof window.document !== "undefined";
// Check for Node.js
const isNode = typeof process !== "undefined" && process.versions?.node;
// Check for Web Worker
const isWebWorker =
typeof self === "object" &&
self.constructor?.name === "DedicatedWorkerGlobalScope";
// Check for Deno
const isDeno = typeof Deno !== "undefined";
// Check for Bun
const isBun = typeof Bun !== "undefined";Environment-Specific Code
// utils/storage.js
let storage;
if (typeof window !== "undefined") {
// Browser: use localStorage
storage = {
get: (key) => localStorage.getItem(key),
set: (key, value) => localStorage.setItem(key, value),
remove: (key) => localStorage.removeItem(key),
};
} else {
// Node.js: use file system or memory
const cache = new Map();
storage = {
get: (key) => cache.get(key),
set: (key, value) => cache.set(key, value),
remove: (key) => cache.delete(key),
};
}
export default storage;Using Conditional Exports
// package.json
{
"exports": {
".": {
"browser": "./src/browser.js",
"node": "./src/node.js",
"default": "./src/node.js"
},
"./utils": {
"browser": "./src/utils.browser.js",
"node": "./src/utils.node.js"
}
}
}Abstracting Platform Differences
Fetch API
// fetch.js - Universal fetch
let universalFetch;
if (typeof fetch !== "undefined") {
universalFetch = fetch;
} else {
// Node.js < 18 needs node-fetch
universalFetch = (...args) =>
import("node-fetch").then((m) => m.default(...args));
}
export { universalFetch as fetch };Universal HTTP Client
// http-client.js
class HttpClient {
constructor(baseURL, options = {}) {
this.baseURL = baseURL;
this.headers = options.headers || {};
// Environment-specific defaults
if (typeof window === "undefined") {
// Node.js: might need to handle HTTPS differently
this.agent = options.agent;
}
}
async request(path, options = {}) {
const url = `${this.baseURL}${path}`;
const config = {
...options,
headers: { ...this.headers, ...options.headers },
};
// Add Node.js specific options
if (this.agent) {
config.agent = this.agent;
}
const response = await fetch(url, config);
if (!response.ok) {
throw new HttpError(response.status, await response.text());
}
return response.json();
}
get(path, options) {
return this.request(path, { ...options, method: "GET" });
}
post(path, data, options) {
return this.request(path, {
...options,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
}
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
export { HttpClient, HttpError };Crypto API
// crypto.js
let crypto;
if (typeof window !== "undefined" && window.crypto) {
// Browser
crypto = {
randomBytes(size) {
const bytes = new Uint8Array(size);
window.crypto.getRandomValues(bytes);
return bytes;
},
async hash(algorithm, data) {
const encoder = new TextEncoder();
const buffer = encoder.encode(data);
const hashBuffer = await window.crypto.subtle.digest(algorithm, buffer);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
},
};
} else {
// Node.js
const nodeCrypto = require("crypto");
crypto = {
randomBytes(size) {
return nodeCrypto.randomBytes(size);
},
async hash(algorithm, data) {
const algMap = { "SHA-256": "sha256", "SHA-512": "sha512" };
return nodeCrypto
.createHash(algMap[algorithm] || algorithm)
.update(data)
.digest("hex");
},
};
}
export default crypto;Bundling for Different Targets
Build Configuration
// rollup.config.js
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import terser from "@rollup/plugin-terser";
const shared = {
input: "src/index.js",
plugins: [nodeResolve(), commonjs()],
};
export default [
// ESM for bundlers
{
...shared,
output: {
file: "dist/index.esm.js",
format: "esm",
},
},
// CommonJS for Node.js
{
...shared,
output: {
file: "dist/index.cjs.js",
format: "cjs",
},
},
// UMD for browsers (CDN)
{
...shared,
output: {
file: "dist/index.umd.js",
format: "umd",
name: "MyLibrary",
},
plugins: [...shared.plugins, terser()],
},
];Handling Node.js Built-ins in Browser
// webpack.config.js
module.exports = {
resolve: {
fallback: {
// Polyfill or ignore Node.js modules
fs: false,
path: require.resolve("path-browserify"),
crypto: require.resolve("crypto-browserify"),
stream: require.resolve("stream-browserify"),
buffer: require.resolve("buffer/"),
},
},
plugins: [
new webpack.ProvidePlugin({
Buffer: ["buffer", "Buffer"],
process: "process/browser",
}),
],
};Warning
Polyfilling Node.js modules significantly increases bundle size. Consider whether the browser really needs this functionality or if there's a native Web API alternative.
Server-Side Rendering (SSR)
Basic SSR Pattern
// server.js
import express from "express";
import React from "react";
import { renderToString } from "react-dom/server";
import App from "./App.js";
const app = express();
app.get("*", (req, res) => {
const appHtml = renderToString(<App url={req.url} />);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
</head>
<body>
<div id="root">${appHtml}</div>
<script src="/client.js"></script>
</body>
</html>
`);
});
// client.js (hydration)
import React from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./App.js";
hydrateRoot(
document.getElementById("root"),
<App url={window.location.pathname} />,
);Data Fetching Pattern
// Shared data fetcher
async function fetchPageData(route) {
const response = await fetch(`/api/page${route}`);
return response.json();
}
// Server
app.get("*", async (req, res) => {
const data = await fetchPageData(req.path);
const appHtml = renderToString(<App data={data} />);
res.send(`
<!DOCTYPE html>
<html>
<body>
<div id="root">${appHtml}</div>
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(data)};
</script>
<script src="/client.js"></script>
</body>
</html>
`);
});
// Client
const initialData = window.__INITIAL_DATA__;
hydrateRoot(document.getElementById("root"), <App data={initialData} />);Universal Route Handler
// routes.js - Shared route definitions
export const routes = [
{
path: "/",
component: () => import("./pages/Home.js"),
fetchData: () => fetch("/api/home").then((r) => r.json()),
},
{
path: "/users/:id",
component: () => import("./pages/User.js"),
fetchData: ({ params }) =>
fetch(`/api/users/${params.id}`).then((r) => r.json()),
},
];
// router.js - Universal router
export function matchRoute(path) {
for (const route of routes) {
const match = matchPath(route.path, path);
if (match) {
return { route, match };
}
}
return null;
}
function matchPath(pattern, path) {
const paramNames = [];
const regexPattern = pattern.replace(/:(\w+)/g, (_, name) => {
paramNames.push(name);
return "([^/]+)";
});
const match = path.match(new RegExp(`^${regexPattern}$`));
if (!match) return null;
const params = {};
paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});
return { path, params };
}
// Usage on server
app.get("*", async (req, res) => {
const { route, match } = matchRoute(req.path);
const data = await route.fetchData(match);
const Component = (await route.component()).default;
// Render...
});
// Usage on client
const { route, match } = matchRoute(window.location.pathname);
// Hydrate...Shared Validation
Validate on both client and server:
// validation.js - Universal validation
export const validators = {
required: (value) =>
value !== undefined && value !== null && value !== ""
? null
: "This field is required",
email: (value) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : "Invalid email address",
minLength: (min) => (value) =>
value && value.length >= min ? null : `Must be at least ${min} characters`,
maxLength: (max) => (value) =>
!value || value.length <= max ? null : `Must be at most ${max} characters`,
};
export function validate(data, schema) {
const errors = {};
for (const [field, rules] of Object.entries(schema)) {
const value = data[field];
for (const rule of rules) {
const error = rule(value);
if (error) {
errors[field] = error;
break;
}
}
}
return {
valid: Object.keys(errors).length === 0,
errors,
};
}
// Schema definition
export const userSchema = {
email: [validators.required, validators.email],
password: [validators.required, validators.minLength(8)],
name: [validators.required, validators.maxLength(50)],
};
// Client usage
const { valid, errors } = validate(formData, userSchema);
if (!valid) {
showErrors(errors);
}
// Server usage
app.post("/api/users", (req, res) => {
const { valid, errors } = validate(req.body, userSchema);
if (!valid) {
return res.status(400).json({ errors });
}
// Create user...
});API Client Pattern
// api-client.js - Works in both environments
class ApiClient {
constructor(baseURL) {
this.baseURL = baseURL;
}
// Shared types and endpoints
async getUser(id) {
return this.get(`/users/${id}`);
}
async createUser(data) {
return this.post("/users", data);
}
async updateUser(id, data) {
return this.put(`/users/${id}`, data);
}
// Internal methods
async get(path) {
return this.request("GET", path);
}
async post(path, data) {
return this.request("POST", path, data);
}
async put(path, data) {
return this.request("PUT", path, data);
}
async request(method, path, data) {
const url = `${this.baseURL}${path}`;
const options = {
method,
headers: {
"Content-Type": "application/json",
...this.getAuthHeaders(),
},
};
if (data) {
options.body = JSON.stringify(data);
}
const response = await fetch(url, options);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(response.status, error.message);
}
return response.json();
}
getAuthHeaders() {
// Environment-specific auth
if (typeof window !== "undefined") {
const token = localStorage.getItem("token");
return token ? { Authorization: `Bearer ${token}` } : {};
}
return {};
}
}
// Shared instance
export const api = new ApiClient(
typeof window !== "undefined"
? "/api" // Browser: relative path
: "http://localhost:3000/api", // Server: absolute URL
);Summary
Universal JavaScript patterns enable code sharing:
| Pattern | Purpose |
|---|---|
| Dual Package | Support both CJS and ESM consumers |
| Environment Detection | Adapt to runtime capabilities |
| Conditional Exports | Different implementations per platform |
| Universal Abstractions | Hide platform differences behind interfaces |
| SSR + Hydration | Render on server, activate on client |
| Shared Validation | Same rules, both sides |
Key considerations:
- Start with shared code, add platform-specific where needed
- Use feature detection over environment detection when possible
- Keep bundles small by avoiding unnecessary polyfills
- Test in all target environments
Note
Modern runtimes (Node.js 18+, modern browsers, Deno, Bun) have converged significantly. Standard Web APIs like fetch, crypto, and URL work everywhere, reducing the need for abstractions.