Chapter 10: Universal JavaScript for Web Applications
Summary
Universal JavaScript (originally called Isomorphic JavaScript) is the practice of writing code that runs on both the server (Node.js) and the client (browser). This chapter covers the entire journey: from making Node.js modules work in browsers via module bundlers, to handling platform differences with cross-platform patterns, to building full server-side rendered (SSR) applications with React.
The key insight is that sharing code between server and browser requires solving
three problems: module compatibility (browsers don't have require),
platform differences (no fs in browsers, no document on servers), and
rendering orchestration (getting server-rendered HTML to seamlessly hand off
to client-side interactivity).
Key Concepts
Sharing Code with the Browser
The Fundamental Problem
Node.js and browsers both run on V8, but sharing code between them is not
trivial. In Node.js, we don't have the DOM or long-living views. On the
browser, we don't have the filesystem and many OS interfaces. Module systems
also differ -- browsers historically had no require() function. Even with
native ESM support, you still need to bundle node_modules dependencies.
Another contention point is ES feature support: on the server you know exactly which Node.js version runs, but browser users may have varying levels of support. The effort is to reduce differences using abstractions, patterns, and tools.
How Module Bundlers Work
A module bundler:
- Starts from an entry point (e.g.,
src/index.js) - Parses
require()/importstatements to build an acyclic dependency graph - Recursively resolves all transitive dependencies
- Wraps each module in a factory function (creating a modules map)
- Outputs one or more bundle files the browser can load via
<script>
Entry Point --> Dependency Graph --> Bundle
index.js |-- moduleA.js bundle.js
| +-- moduleC.js (modules map:
+-- moduleB.js factory functions
+ require() shim)
The bundler's runtime mimics Node.js's require() caching behavior -- each
module factory executes only once, and subsequent require() calls return the
cached module.exports.
Webpack
Webpack is the bundler covered in this chapter. Core concepts:
// webpack.config.js
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{ test: /\.jsx?$/, use: 'babel-loader' }
]
},
plugins: [
new webpack.DefinePlugin({
'__BROWSER__': true
})
]
}
- Loaders: Transform files before bundling (Babel for JSX/ES, css-loader for CSS)
- Plugins: Modify the bundling process (DefinePlugin, TerserPlugin for minification, HtmlWebpackPlugin)
- Dev server: Development server with hot reloading
- Cache busting: Content hashes in filenames for cache invalidation
- Code splitting: Break bundles into chunks loaded on demand via
import()
Cross-Platform Development Fundamentals
Three strategies for handling platform differences:
1. Runtime Code Branching
if (typeof window !== 'undefined') {
// Browser-specific code
const data = window.localStorage.getItem('key')
} else {
// Node.js-specific code
const data = fs.readFileSync('/path/to/file')
}
Downside
Both branches end up in the bundle. Dead code is included but never executed.
If the dead branch uses require('fs'), that dependency is still bundled
(webpack provides an empty shim for fs in browser mode).
Fine for small checks, wasteful for large platform-specific dependencies.
2. Build-Time Code Branching
// webpack.config.js
plugins: [
new webpack.DefinePlugin({
'__BROWSER__': JSON.stringify(true)
})
]
// In your code:
if (__BROWSER__) {
renderToDom()
} else {
renderToString()
}
The bundler replaces __BROWSER__ with true, then TerserPlugin (the
minifier) eliminates the dead else branch entirely -- including any
require() calls within it. Result: smaller, platform-specific bundles.
3. Module Swapping
Replace entire modules based on platform:
// package.json
{
"browser": {
"./src/storage.js": "./src/storage-browser.js",
"fs": false
}
}
Or via webpack config:
resolve: {
alias: {
'./storage': './storage-browser'
}
}
Setting a module to false provides an empty module (no exports).
Node.js ignores 'browser'
The browser field in package.json is only read by bundlers, not by
Node.js itself. Node.js always uses the main field for resolution.
React Fundamentals
createElement and JSX
// The raw API
React.createElement('h1', { className: 'title' }, 'Hello World')
// JSX (syntactic sugar, transpiled to the above)
<h1 className="title">Hello World</h1>
Components are functions that return elements:
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>
}
Stateful Components
import { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
Building a Universal JavaScript App
The book walks through a progressive evolution from SPA to full SSR:
Step 1: Frontend-Only SPA
Server sends a minimal HTML shell, all rendering happens in the browser:
<div id="root"></div>
<script src="/bundle.js"></script>
import { createRoot } from 'react-dom/client'
createRoot(document.getElementById('root')).render(<App />)
Problem: Empty HTML until JS loads. Bad for SEO, slow first paint.
Step 2: Server-Side Rendering
// Server
import { renderToString } from 'react-dom/server'
app.get('*', (req, res) => {
const html = renderToString(<App />)
res.send(`
<html>
<body>
<div id="root">${html}</div>
<script src="/bundle.js"></script>
</body>
</html>
`)
})
// Client (hydration)
import { hydrateRoot } from 'react-dom/client'
hydrateRoot(document.getElementById('root'), <App />)
renderToString vs hydrateRoot
renderToString() produces static HTML on the server. hydrateRoot() on the
client attaches event listeners to the existing DOM without re-creating it.
The server and client must produce the same HTML, or React warns about
hydration mismatches.
Step 3: Async Data Challenge
renderToString() is synchronous, but data fetching is async:
// This won't work -- renderToString can't wait for useEffect/fetch
const html = renderToString(<App />) // renders with empty state
Step 4: Universal Data Retrieval
Fetch data before rendering, pass it as props, and serialize it for the client:
// Server
const data = await fetchDataForRoute(req.url)
const html = renderToString(<App initialData={data} />)
res.send(`
<html>
<body>
<div id="root">${html}</div>
<script>
window.__INITIAL_STATE__ = ${JSON.stringify(data)}
</script>
<script src="/bundle.js"></script>
</body>
</html>
`)
// Client
const initialData = window.__INITIAL_STATE__
hydrateRoot(
document.getElementById('root'),
<App initialData={initialData} />
)
Serialization Pitfall
When embedding data in <script> tags, you must sanitize the JSON to prevent
XSS (escape </script> strings within the data). Libraries like
serialize-javascript handle this safely.
Step 5: Two-Pass Rendering
For complex apps with nested data dependencies:
Pass 1: Render tree --> discover data requirements (no HTML used)
|
Fetch all required data
|
Pass 2: Render tree with data --> produce final HTML
This allows deeply nested components to declare their own data needs without the server needing to know the component tree structure up front.
Step 6: Async Pages
Route-based code splitting + SSR: each page defines its data requirements:
// Each page component has a static data fetching method
Page.fetchData = async (params) => {
return api.getData(params.id)
}
// Route handler
app.get('/page/:id', async (req, res) => {
const data = await Page.fetchData(req.params)
const html = renderToString(<Page data={data} />)
// ... send response with embedded data
})
Mind Map
Connections
- Builds on: Chapter 2 -- The Module System (require/import, module resolution)
- Builds on: Chapter 9 -- Behavioral Design Patterns (Strategy pattern for cross-platform)
- Related: Chapter 8 -- Structural Design Patterns (Adapter pattern for cross-platform)
- Next: Chapter 11 -- Advanced Recipes