What Is Isomorphic JavaScript?
Older Article
This article was published 7 years ago. Some information may be outdated or no longer applicable.
Isomorphic (or “Universal”) JavaScript has been floating around as a concept for years. Let’s crack open what it actually means and why you’d want it.
Isomorphism and Isomorphic
Before we get into JavaScript specifics, let’s pin down the word itself. Isomorphism comes from mathematics, rooted in Greek: “iso” meaning equal, “morphosis” meaning to shape. Two things that share the same form.
So we’re looking at two pieces of code that are identical. But it’s more than copy-paste duplication.
Think about it. When would two pieces of code need to be identical? In JavaScript’s case, isomorphic (or universal) JavaScript refers to the same code running on the client (browser) or the server (Node.js). It’s JavaScript that executes the same way regardless of where it runs.
The terms isomorphic and universal get used interchangeably. There are plenty of online debates about whether they’re truly synonymous or subtly different, but for this article, we’ll treat them as the same thing: one piece of JavaScript code that runs on both client and server.
Isomorphic Vanilla JavaScript
Here’s the thing that gets people excited: isomorphic JavaScript isn’t limited to frameworks like Angular. You can write vanilla JavaScript that runs on both client and server. Frameworks hand you this capability automatically, sure. But you can build it yourself.
I self-published a book about Progressive Web Applications where I walk through exactly this. The same code generates HTML on the server, then gets reused client-side inside the service worker. Vanilla isomorphic JavaScript, powering a PWA.
Consider this code, which uses Node.js to render an HTML page (custom server-side rendering built from scratch):
import { generateCard } from './utils.mjs';
// additional code, removed for brevity
app.get('/', async (req, res) => {
res.write(fs.readFileSync('partials/header.html'));
res.write(fs.readFileSync('partials/info.html'));
res.write(fs.readFileSync('partials/hero.html'));
res.write(fs.readFileSync('partials/articles.html'));
const articles = await (await fetch('http://localhost:3000/api/news')).json();
articles.forEach((article) => {
const card = generateCard(article);
res.write(card);
});
res.write(fs.readFileSync('partials/articles-close.html'));
res.write(fs.readFileSync('partials/footer.html'));
res.end();
});
This renders an index page by streaming all the partial HTML files and hitting a REST API to build article cards (the app is a “news” reader).
Notice the imported function from utils.mjs:
export function generateCard(article) {
const today = new Date();
const added = new Date(article.added);
const difference = parseInt((today - added) / (1000 * 3600));
return `
<div class="col-md-4">
<div class="card mb-4 shadow-sm">
<div class="card-body">
<p class="card-text">${article.slug}</p>
<div class="d-flex justify-content-between align-items-center">
<div class="btn-group">
<a href="news/${
article.id
}" class="btn btn-sm btn-outline-primary">Read more</a>
<a href="#" class="btn btn-sm btn-outline-secondary read-later">Read later</a>
</div>
<small class="text-muted">${
difference === 1
? `${difference} hour ago`
: `${difference} hours ago`
}</small>
</div>
</div>
</div>
</div>`;
}
This mimics a template system using ES6 Template Literals. It grabs article data (an object) and stamps out a card with key information.
Now, we also need to stitch HTML together on the client side. Why? Because it’s a PWA, and the service worker needs to render the same data when the network’s unavailable.
Rewriting that logic would be wasteful. Instead, we pull in the exact same function inside the service worker:
import { generateCard } from './utils.mjs';
// additional code
workbox.routing.registerRoute(
'/',
workbox.streams.strategy([
() =>
cacheStrategy.makeRequest({
request: workbox.precaching.getCacheKeyForURL('partials/header.html'),
}),
() =>
cacheStrategy.makeRequest({
request: workbox.precaching.getCacheKeyForURL('partials/info.html'),
}),
() =>
cacheStrategy.makeRequest({
request: workbox.precaching.getCacheKeyForURL('partials/hero.html'),
}),
() =>
cacheStrategy.makeRequest({
request: workbox.precaching.getCacheKeyForURL('partials/articles.html'),
}),
async ({ event, url }) => {
try {
const response = await apiStrategy.makeRequest({
event,
request: apiRoute,
});
const articles = await response.json();
let cards = '';
articles.forEach((article) => {
cards += generateCard(article);
});
return cards;
} catch (error) {
console.error(error);
}
},
() =>
cacheStrategy.makeRequest({
request: workbox.precaching.getCacheKeyForURL(
'partials/articles-close.html'
),
}),
() =>
cacheStrategy.makeRequest({
request: workbox.precaching.getCacheKeyForURL('partials/footer.html'),
}),
])
);
Same generateCard from utils.mjs. Same code. But this time, Workbox builds the index file from cached entries and cached API data.
That’s the whole trick. Write code that both the browser and the server can consume.
Limitations
There are obvious boundaries. You can’t touch the window object on the server, for instance. If you want JavaScript to run in both environments, you’ve got to avoid packages and objects that belong to only one of them.
Conclusion
Isomorphic JavaScript opens up a lot of possibilities. In this article, we walked through a Progressive Web Application built with vanilla JavaScript, sharing code between client and server. It’s worth experimenting with on your own projects.