Photo by Antonio Batinić: https://www.pexels.com/photo/black-screen-with-code-4164418/
Photo by Antonio Batinić: https://www.pexels.com/photo/black-screen-with-code-4164418/

Framework for creating Isomorphic React Apps

Devesh Kumar

Devesh Kumar

Thu May 26 2022
19 Min Read

React is a really popular library for creating amazing and interactive frontend apps. If you are even loosely familiar with JavaScript and React, you know that React is intended for Client-Side Rendered apps by default, and SEO, i.e: something that depends on sending page data on the server-side to Search Engine crawlers has not always been its forte.

To bridge this gap, a new pattern that's gained steam recently is creating Isomorphic React apps. I.E: React apps that can work on both the server and client-side, in this post, we'll look at how to create one from scratch. Although React does have support for server-side rendering built-in, it often takes some work to set it up. A great framework that pioneered this approach was Next.js, if you're new to this blog, you know that I'm a fan of Next.js with the plethora of features it provides.

By "can work on the server", I do not mean that it will handle server interactions, but rather that it can render a screen on the server-side instead of just sending a blank HTML page that is populated on the client-side by JavaScript. Check my post on How Server-Side Rendering With React works.

Software Engineering to me is all about a never-ending drive that keeps asking you "Can you build this?"

Hence in this post, inspired by "What I cannot build, I do not understand", we'll be creating a framework inspired by Next.js to build our own react apps with routing, that can provide a rendered version on the server as well as be completely interactive for the user on their devices. Best of both worlds.

As above, this post will build heavily on this post for concepts and patterns of Server-rendered React apps: How Server-Side Rendering With React works.

Features We'll be building

  • Full support for Server-Side Rendering of our App's pages.
  • Full interactivity for Client-Side Rendering and interactivity of the App's pages for end-users.
  • Support for Static pages (I.E: Render on the server once and cache for all later requests)
  • Data fetching for populating page's content on the server-side, i.e: Functionality like getServerSideProps and getStaticProps along with a hook to get that data across the entire app at any component.
  • Custom Error pages to handle 404 and 500 errors.
  • Caching of Page JavaScript bundles in production
  • Tree-shaking (Dead-code elimination) for the bundle code
  • Support for environment variables, both on client and server-side. All environment variables starting with ISOMORPH_PUBLIC_ will be exposed to the client-side as well.

What We'll be using

  • Node.js
  • Express for setting up our server
  • React and ReactDOM for rendering our React Apps
  • Babel for compiling our JSX and ES6+ code to regular JavaScript.
  • Browserify for bundling our React apps and their dependencies into JavaScript that can run on the browser, along with other libraries that act as Browserify plugins.

To follow this post, and to also see a fully functional installable package, you can check out the corresponding Repository for Isomorph on GitHub.

Index

Given this is a long post, here's a link to the sections we're convering in this post:

Setting Up Our App

Let's set up our app, plainly and simply. Create a directory like my-isomorphic-react-app. Then inside the directory, run the following:

npm init -y # Creates a package.json file for us and initializes the directory as a Node.js workspace
npm i --save express nodemon react react-dom

For compiling our code from ES6+ to ES5, we'll need Babel.

npm i --save @babel/cli @babel/core @babel/node @babel/preset-env @babel/preset-react

Then create a server.js file and add the following content to it:

import express from "express";

const app = express();

app.get("*", async (req, res) => {
	// For handling all requests at our server.
	return res.sendStatus(200);
});
const PORT = process.env.PORT || 5432;
app.listen(PORT, () => {
	console.log(`Listening at port: ${PORT}`);
});

Now we have set up our express app server file. We need to set up Babel to tell it how to compile our app down to ES5, given Node by default doesn't yet support the import syntax and the JSX syntax we'll be using for our React components soon. For doing so, we'll define a babel.config.json at our root with the following content:

{
	"presets": ["@babel/preset-env", "@babel/preset-react"],
	"comments": false
}

To set up compilation for our code, we'll add scripts to our npm file.

"scripts": {
+	"build": "babel ./src --out-dir ./.isomorph",
+	"build:watch": "babel --watch ./src --out-dir ./.isomorph",
+	"dev": "concurrently \"npm run build:watch\" \"nodemon ./.isomorph/server.js\"",
+	"start": "node ./isomorph/server.js"
},

With the above, we'll compile our code to the dist folder.

You might have noticed the dev command, for the active development of our project, in that case, we'll have to compile our code with all changes and listen to changes for restarting our server automatically using nodemon. To do so, we'll use concurrently to run both our build:watch command and nodemon ./.isomorph/server.js command concurrently.

npm i --save concurrently
npm run dev

Handling Page Requests

Let's assume our pages are going to be structured similarly to Next.js, in an src/pages folder.

We can now handle requests to page endpoints in two ways:

  • Have a wildcard route request listener that listens to all requests using *, and then sends back the appropriate page and its bundle. This is the approach we'll use since it makes handling 404 and 500 errors simpler in a central controller.
  • On startup of the server, generate route listeners for all the pages dynamically, this is simpler in case you want to handle routes with dynamic patterns, it can be done with the first approach as well, however, and this approach is more cumbersome.
app.get("*", async (req, res) => {
	const pageRoute = req.url;
	const pageImportPath = `pages${
		pageRoute.endsWith("/") ? pageRoute + "index" : pageRoute
	}`;
	const ComponentExports = await import(pageImportPath);
});

Here ComponentExports will be an object with the following structure (We'll handle checking for the existence of files and import errors in further sections of this post):

{
    "default": "<PageComponent",
    ... Other exports from the page file.
}

Hence we'll get Page's default export (The Page Component) in a variable:

const { default: ComponentDefault } = ComponentExports;

We'll also create our central WrapperComponent that takes care of things like data passing to our page component, common config and common code in further sections.

import InitialDataContextProvider from "./InitialDataContextProvider";
const WrapperComponent = ({ Component }) => {
	return <Component />;
};
export default WrapperComponent;

Sending Back server-rendered React Component

Sending back our page's React component is pretty simple. We simply use ReactDOM/server's renderToString function to compile our React component with a WrapperComponent to a string, inject it into an HTML template and send it back.

import WrapperComponent from './WrapperComponent';
import { renderToString } from "react-dom/server";

app.use('*', async (req, res) => {
    ...

    const componentOutput = renderToString(
       // We'll look at initial data processing in the next section
        <WrapperComponent Component={ComponentDefault} />
    );

    const pageHTMLGenerated = `
        <html>
            <body>
                <div id="isomorph_root">
                    ${componentOutput}
                </div>
            </body>
        </html>
    `;

    if (!res.headersSent) return res.send(pageHTMLGenerated);
});

Setting Up Data Fetching

To pre-render our pages on the server, there is inevitably going to be some data we would want to fetch. For example, a blog post page might be using an independent Content Management System like Strapi from which the data has to be fetched. In such cases, Next.js provides two distinct data fetching methods (Three if you count getInitialProps).

Namely, getStaticProps for static pages that require some data fetching (Blog post pages, Terms and conditions pages etc) and getServerSideProps for server-rendered pages (Data that is dynamic based on the user visiting the page, often identified by a cookie in the request, or fast-changing E-Commerce product pages).

Refer to this post to know how data fetching and prefetching based on it works.

We'll allow our pages to follow the same pattern of fetching the data on the server and receiving it as props/data accessible throughout the page via a hook named useInitialData.

const {
	default: ComponentDefault, // The React component for the page
	getPropsOnServer = nullFunction,
	getStaticProps = nullFunction,
} = ComponentExports;
const context = generateServerSideContext(req, res, isStaticPage);
let [initialProps, staticProps] = await Promise.all([
	getPropsOnServer(context),
	getStaticProps(context),
]);

Here generateServerSideContext is a helper function that generates a standard object for server-rendered or static pages.

const generateServerSideContext = (req, res, isStaticPage = false) => {
	if (isStaticPage) return { env: process.env, url: req.url };
	return {
		req,
		res,
		cookies: req.cookies,
		url: req.url,
		query: req.query,
		params: req.params,
		env: process.env,
	};
};

export default generateServerSideContext;

Now using the initialProps or staticProps depending on the choice of the page, we'll pass this data on the server to our WrapperComponent.

const initialData = isStaticPage ? staticProps : initialProps;
const componentOutput = renderToString(
	<WrapperComponent Component={ComponentDefault} pageProps={initialData} />
);

Now to ensure this data fetched will be accessible inside every component on the page, we'll use React's Context API to propagate this data by creating an InitialDataContext.

// InitialDataContext.js
import { createContext, useRef } from "react";

export const InitialDataContext = createContext({});

const InitialDataContextProvider = ({ initialProps, children }) => {
	let initialData = useRef({ ...(initialProps || {}) });

	if (!initialProps && typeof window !== "undefined") {
		try {
			// On the client, read from the script tag created on the server side with initial props.
			const dataScriptTag = document.querySelector(
				'script[type="isomorph/data"]'
			);
			if (dataScriptTag)
				initialData.current = JSON.parse(dataScriptTag.innerHTML);
		} catch {
			console.error(
				"Invalid data passed from the server, please check your data loader hooks or file a bug."
			);
		}
	}

	return (
		<InitialDataContext.Provider value={initialData.current}>
			{children}
		</InitialDataContext.Provider>
	);
};

export default InitialDataContextProvider;

To have this data available using a hook, we'll create a useInitialData hook (This is more of a pattern inspired by Remix than Next.js):

// hooks/useInitialData.js
import { useContext } from "react";
import { InitialDataContext } from "../InitialDataContextProvider";

const useInitialData = () => useContext(InitialDataContext);

export default useInitialData;

Now you might wonder, what's up with the script tag check block for Initial Data Context on the client-side, well that's because we'll be creating a script tag with a specific ID, stringifying our props and reading it on the client-side before rendering the app so that the page renders with all the data available to it upfront.

// In server.js, we tweak the initial data for the client side.
const pageHTMLGenerated = `
...
<head>
	<script type="isomorph/data">${JSON.stringify(initialData)}</script>
</head>
...`;

And finally, for our WrapperComponent, we'll wrap our Context Provider around our page component.

// On server side: This component simply passes pageProps to the component
// On client-side: While rendering this component picks up the page props from the script tag and passes it to the component.
return (
+	<InitialDataContextProvider initialProps={pageProps}>
		<Component />
+	</InitialDataContextProvider>
);

Rendering the page on the Client-Side

We are done with rendering the page on the server side, now comes the toughest part, rendering the page on the client-side. It is one of those things every documentation tells you is possible using ReactDOM's render and hydrate functions, but no one tells you how.

Took a little digging, and the simplest approach is here:

  • Create a template string that imports the page component and the wrapper component.
  • Compile the code string using Babel to convert it to ES5
  • Bundle the compiled code using Browserify to put all the required dependencies in a bundle (We'll tree-shake dead code in the next section).
  • Embed the output to a script tag in the HTML sent to the client. So that the client-side rendering code is executed and the page becomes interactive on the client.

Let's see our client-side rendering template generator:

// src/utils/clientSideHydrationCodeGenerator.js
const getClientSideHydrationCode = (pageImportPath) => `
    import React from 'react';
    import ReactDOM from 'react-dom/client';

    window.React = React;
    
    import WrapperComponent from './node_modules/isomorph-web/package/WrapperComponent';
    import PageComponent from './.isomorph/${pageImportPath}';
    
    // Can use hydrate as well, but I want to keep the DOM on the client side fresh to remove any rendering inconsistencies that could creep in.
    const rootElement = document.getElementById("isomorph_root");
    const root = ReactDOM.createRoot(rootElement);
    root.render(<WrapperComponent Component={PageComponent} />);
`;

export default getClientSideHydrationCode;

In our server.js file, we'll use browserify and a plugin called babelify to first compile the template for the page component, and then bundle dependencies into a string that we can inject into the HTML for the page.

Before that, we need a package called string-to-stream as Browserify's API requires a Stream instead of a string.

npm i --save string-to-stream

We'll also add a utility function to convert Browserify's output to a string to inject into the HTML file.

// src/utils/streamToString.js
const streamToString = (stream) => {
	return new Promise((resolve) => {
		let string = "";
		stream.on("data", function (data) {
			string += data.toString();
		});

		stream.on("end", function () {
			resolve(string);
		});
	});
};

export default streamToString;

Putting it all together gives us:

// ...
import compileCodeToStream from "string-to-stream";
import babelConfig from "../babel.config.json";
import streamToString from "./utils/streamToString";

// ... Inside our request handler
const clientSideHydrationCode = getClientSideHydrationCode(pageImportPath);
const pageBundle = browserify()
	.transform("babelify", {
		presets: babelConfig.presets,
		comments: babelConfig.comments,
	})
	.add(compileCodeToStream(clientSideHydrationCode))
	.bundle();
const clientSideBundleString = await streamToString(pageBundle);
const pageHTMLGenerated = `
    <html>
        <head>
            <title>App Rendered By Isomorph</title>
            <script type="isomorph/data">${JSON.stringify(initialData)}</script>
        </head>
        <body>
            <div id="isomorph_root">${componentOutput}</div>
            <!-- Client Side Rehydration Chunk for the page -->
            <script type="text/javascript">
                ${clientSideBundleString}
            </script>
        </body>
    </html>
`;

Post sending this HTML back, we will get a page that will be fully interactive for the end-users and will re-render once to ensure all client-side data requirements are fulfilled.

You can differentiate whether you're on the server or the client using this expression inside your components:

typeof window === "undefined";

Tree Shaking and Environment Variables support

For tree-shaking and environment variables, we can use tinyify plugin for Browserify to minify and tree-shake any unused dependencies. We'll also make sure it's only minified and tree-shaken on production.

let browserifyInstance = browserify()
	.transform("babelify", {
		presets: babelConfig.presets,
			comments: babelConfig.comments,
		})

+ if (isProd) {
+	// Tree shaking and minification + bundling of modules to production mode.
+	browserifyInstance = browserifyInstance.plugin(tinyify);
+}

For environment variables, we can use dotenv to read environment variables from a .env file.

npm i --save dotenv
// At the top of our server.js file.
import { config } from "dotenv";
config();

Now that we've read the environment variables we need from our .env file, we'll also want to compile our bundle with those environment variables and expose public environment variables starting with ISOMORPH_PUBLIC_ to the browser-side processes.

For the first, we have a nice plugin called envify for browserify that takes care of environment variables in code for us.

let browserifyInstance = browserify()
	.transform("babelify", {
		presets: babelConfig.presets,
		comments: babelConfig.comments,
	})
+	.transform(envify({ NODE_ENV: process.env.NODE_ENV }));

To expose public environment variables, we'll add process.env as a global object using a script tag to our component code that sends the initial response from the server.

<!-- Public environment and browser variables to use later on the client-side if needed -->
<script type="text/javascript">
	window.process = {
		browser: true,
		env: ${JSON.stringify(processPublicEnvVars())}
	};
</script>

Where processPublicEnvVars is:

const processPublicEnvVars = () => {
	const envList = {};
	for (let key in process.env) {
		if (process.env.hasOwnProperty(key))
			if (key.startsWith("ISOMORPH_PUBLIC_")) envList[key] = process.env[key];
	}
	return envList;
};

Server-Side Rendering is incomplete without its biggest feature, which is the ability to improve metadata for the page and in turn improve the SEO and discoverability of pages.

We'll follow a pattern similar to Remix here, and provide a getPageMeta function alongside our data fetcher functions, to dynamically process and return an object that will contain stuff like meta tags, link tags and script tags.

const {
	default: ComponentDefault, // The React component for the page
	getPropsOnServer = nullFunction,
	getStaticProps = nullFunction,
+        getPageMeta = nullFunction
} = ComponentExports;
const context = generateServerSideContext(req, res, isStaticPage);
let [
    initialProps,
    staticProps,
    pageMeta
] = await Promise.all([
    getPropsOnServer(context),
    getStaticProps(context),
+    getPageMeta(context)
]);

The structure of what getPageMeta will return will be like the following:

{
	title: "Home Page",
	meta: [
		{
			name: "description",
			content: "A simple page generated on the server-side.",
		},
		{
			name: "viewport",
			content: "width=device-width, initial-scale=1.0",
		},
	],
	links: [
		{
			rel: "stylesheet",
			href: "https://cdn.jsdelivr.net/npm/bootstrap@4.3.1/dist/css/bootstrap.min.css",
		},
	],
	scripts: [
		{
			type: "application/ld+json",
			content: '{ type: "entity" }',
		},
	]
}

Using the above information, we can then populate our page's HTML with a head tag containing processed information

// In server.js, we tweak the code in the head tag for the page.
const pageHTMLGenerated = `
...
<head>
    ...
    ${generatePageMetaHTML(pageMeta || {})}
</head>
...`;

Where generatePageMetaHTML is a simple processor util function that takes the information returned from getPageMeta and gives us back HTML to inject into the page's code:

// utils/generatePageMetaHTML.js
const generateAttributesHTML = (entity) => {
	let attributeHTML = "";
	for (let attribute of Object.keys(entity)) {
		attributeHTML += `${attribute}="${entity[attribute]}"`;
	}
	return attributeHTML;
};

const generatePageMetaHTML = (pageMeta = {}) => {
	const metaTags = pageMeta?.meta || [];
	const linkTags = pageMeta?.links || [];
	const scriptTags = pageMeta?.scripts || [];

	let generatedHTML = "";

	for (let i = 0; i < metaTags.length; i++)
		generatedHTML += `<meta ${generateAttributesHTML(metaTags[i])} />`;

	for (let i = 0; i < linkTags.length; i++)
		generatedHTML += `<link ${generateAttributesHTML(linkTags[i])}></link>`;

	for (let i = 0; i < scriptTags.length; i++) {
		// No additional attributes
		const {
			content = "",
			id = "",
			className = "",
			type = "text/javascript",
		} = scriptTags[i];
		generatedHTML += `<script id="${id}" class="${className}" type="${type}">${content}</script>`;
	}

	return generatedHTML;
};

export default generatePageMetaHTML;

Handling Static Pages

If you've used Next.js (Which you probably have if you're reading this), you would know Next.js has great support for static pages. Static Pages are pages that are built once and cached for some time, they are not rendered over and over again to ensure that if a page is in the cache, it will always be fast to serve.

We'll identify a static page by the presence of getStaticProps or the absence of getPropsOnServer in the page file's exports.

const isStaticPage =
	!ComponentExports.getPropsOnServer || ComponentExports.getStaticProps;

When a new request comes in for a static page for the first time, we'll build the page bundle and cache it in a simple .html file before sending it to the client-side using the outputFile function from the fs-extra package that takes care of creating directories if they don't exist automatically.

if (isStaticPage) {
	// Write new HTML generated for this page to cache.
	outputFile(
		`./.isomorph/staticpages/${pageImportPath}.html`,
		pageHTMLGenerated
	);
}

We'll also maintain a local in-memory cache for the server to determine which pages have been cached already and which ones haven't yet.

// src/utils/staticPageCache.js
const staticPagesRevalidationCache = {
	// [path] -> lastRevalidation
};

/**
 * Function to check whether a static page should revalidate or not. Also updates the cache entry for the specified path.
 * @param {String} path : URL/pathname of the static page
 * @param {Number} revalidationInterval : Revalidation Interval in seconds for the static page.
 * @returns {Boolean} Whether the page should revalidate or not.
 */
const shouldPageRevalidate = (path, revalidationInterval) => {
	let pageEntryInCache = staticPagesRevalidationCache[path];
	if (!pageEntryInCache) {
		staticPagesRevalidationCache[path] = new Date();
		return true; // Has to be revalidated and cached
	}
	const now = new Date().getTime();
	if (
		(now - new Date(pageEntryInCache[path]).getTime()) / 1000 >
		revalidationInterval
	) {
		// Time since last revalidation is more than the revalidation interval
		staticPagesRevalidationCache[path] = new Date(now);
		return true;
	}
	return false; // No revalidation needed yet.
};

export default shouldPageRevalidate;

The next time the request comes for a static page, we'll check the disk for the existence of the page's compiled HTML file, if it exists, we'll send that back, if it doesn't, we'll go ahead to process the request further. (We also have a check for production environments, you don't want to cache pages when you're actively developing features on local development environments.)

const ComponentExports = await import(pageRelativePath);
const isStaticPage =
	!ComponentExports.getPropsOnServer || ComponentExports.getStaticProps;
if (isStaticPage && isProd) {
	try {
		// Follow the Stale-While-Revalidate approach, serve the static HTML saved first.
		// Then later on, create the page and store the HTML back to the cache.
		const cachedHtmlContentForStaticPage = readFileSync(
			resolve(process.cwd(), `./.isomorph/staticpages/${pageImportPath}.html`),
			{ encoding: "utf-8" }
		);
		res.send(cachedHtmlContentForStaticPage);
	} catch {}
}

If the page was served or not served, we can go ahead in the background to process the rest of the request if the page's revalidation interval is up (Can be specified just like Next.js with the revalidate property passed back from getStaticProps.

const shouldRunRestOfTheCode =
	!isStaticPage || isDev
		? true
		: shouldStaticPageRevalidate(req.url, staticProps?.revalidate || Infinity);

if (!shouldRunRestOfTheCode) return;

Bundle Caching

Once the client-side bundle has been generated, we probably don't need to generate it over and over again in a production environment, doing so will only slow down each request unnecessarily.

Instead, we can store the bundle once to a JavaScript file on the disk on the first request for that page, and instead of injecting the entire JavaScript into the HTML file for execution, we can tweak the script tag to instead link to the bundle file in the page-chunks folder.

We'll use Express' static file serving to serve the files from the disk.

// Before the app.get('*', ...) block.

app.use("/chunks", express.static("./.isomorph/page-chunks"));
// Time to generate the client-side bundle required to rehydrate/re-render the app on the client-side.
let clientSideBundleString;
if (isProd) {
	// On Prod, check if there already exists a prebuilt page bundle.
	// In case it does, there's no need to generate a new bundle for the page on each request.
	const alreadyBuiltPageBundle = pageClientSideBundleExists(pageImportPath);
	if (alreadyBuiltPageBundle) clientSideBundleString = true;
}
+ if (!clientSideBundleString) {
	const clientSideHydrationCode = getClientSideHydrationCode(pageImportPath);
   ...
	const pageBundle = browserifyInstance
		.add(compileCodeToStream(clientSideHydrationCode))
		.bundle();
	clientSideBundleString = await streamToString(pageBundle);
+	writeClientSidePageBundle(pageImportPath, clientSideBundleString);
}

Where writeClientSidePageBundle is a util function that writes our page's bundle in the background to the disk.

// src/utils/writeClientSidePageBundle.js
const fs = require("fs-extra");

const writePageBundle = (pageImportPath, bundle) => {
	try {
		fs.outputFileSync(`.isomorph/page-chunks/${pageImportPath}.js`, bundle);
	} catch (err) {
		console.log(err);
	}
};
export default writePageBundle;

Now since we store the bundle file in the page-chunks folder inside .isomorph accessible directly via a request through /chunks/{pageName}.js, we can link to it in the script tag where we sent the entire page bundle before. This makes the HTML page super-light and decreases the time it takes to first load the page, the JavaScript responsible for client-side rendering and interactivity injection can download in the background and take control as soon as it's ready.

<!-- Client Side Rehydration Chunk for the page -->
- <script type="text/javascript">${clientSideLoadBundle}</script>
+ <script type="text/javascript" src="/chunks/${pageImportPath}.js"></script>

For server-side requests, user auth information is important, and most of the time those requests are made based on a cookie stored in the request context. If you noticed in the generateServerSideContext block, for server-rendered pages we give back a cookies property using req.cookies, well, for getting a key-value object for cookies in a request, we have to use the cookie-parser middleware for Express.

npm i --save cookie-parser
// src/server.js
import cookieParser from "cookie-parser";
...
app.use(cookieParser());  // Populates req.cookie for us, to be used in server-side requests to a page.

Custom Error Pages

So far we've covered all aspects of creating and serving pages, but being developers means you'll often run into a lot of unknown and unexpected errors, module imports fail, users hit an endpoint that doesn't exist, backend API contracts break, etc. In such cases we don't want to serve a broken version of our page or a blank screen to our users, hence this is where error handling comes in.

Let's set up some basic handling for 404 (Page not found errors) and 500 (Internal Server Errors) status codes.

We'll also have the flexibility of using a custom _error.jsx page to serve a custom view for 404 and 500 status code errors, we'll pass it the statusCode prop as well as error which will be the error message.

In the request handler:

const pageRoute = req.url;
const pageImportPath = `pages${
	pageRoute.endsWith("/") ? pageRoute + "index" : pageRoute
}`;
+ // Send 404 response if page file does not exist.
+ const pageRelativePath = resolve(
+ 	process.cwd(),
+ 	`./.isomorph/${pageImportPath}`
+ );
+ const pageFilePresent = await pageFileExists(pageRelativePath);
+ if (!pageFilePresent) {
+ 	const { default: sendBackErrorResponse } = await import(
+ 		"./utils/sendBackErrorResponse"
+ 	);
+ 	return sendBackErrorResponse(res, 404, "Page Not Found");
+ }

We'll wrap the remaining request handler in a try-catch handler, in whose catch block.

try{
    ... Remaining part of the request handling code
}
catch (err) {
        if (res.headersSent) return;
        const { default: sendBackErrorResponse } = await import(
		"./utils/sendBackErrorResponse"
	);
        return sendBackErrorResponse(res, 500, err.message);
}

Now there are two functions that we need to take a look at, sendBackErrorResponse, it's a function that does two things (I know, functions should ideally do only one thing, but let it be an exception for now):

  • Checks if there is a custom _error page present.
  • If yes, render it on the server with the status code and error message and return it as the response.
// src/utils/sendBackErrorResponse.js
import ReactDOMServer from "react-dom/server";

const sendBackErrorResponse = async (res, statusCode, error) => {
	const { default: getErrorComponent } = await import("./getErrorComponent");
	const DefaultErrorComponent = () => (
		<>
			<b>{statusCode}</b> | {error}
		</>
	);
	const ErrorComponent = (await getErrorComponent()) || DefaultErrorComponent;
	return res
		.status(statusCode)
		.send(
			ReactDOMServer.renderToString(
				<ErrorComponent error={error} statusCode={statusCode} />
			)
		);
};

export default sendBackErrorResponse;
// src/utils/getErrorComponent.js
const getErrorComponent = async () => {
	try {
		const { default: ErrorComponent } = await import("./src/pages/_error");
		return ErrorComponent;
	} catch {
		return null; // No error component present or error-free error handler component.
	}
};
export default getErrorComponent;

This handles the case when we want to send custom error pages with statusCode and error. Now let's see how we determine 404 errors as well. The logic is pretty simple, we'll check if src/pages/${pagePath} is present or not. There's a slight catch, with JavaScript there are numerous import paths available with extensions like .js, .jsx, .ts and .tsx. So we'll have to check for the existence of either one.

We'll use the existsSync function to check so.

// src/utils/pageFileExists.js
import { existsSync } from "fs";

const pageFileExists = async (pageFilePath) => {
	const pageFilePossibleNames = [
		`${pageFilePath}.js`,
		`${pageFilePath}.ts`,
		`${pageFilePath}.jsx`,
		`${pageFilePath}.tsx`,
	];

	const checkForFileExistence = (filePath) =>
		new Promise((resolve) => resolve(existsSync(filePath)));

	return (await Promise.all(pageFilePossibleNames.map(checkForFileExistence))) // Parallelize exists operation for page file
		.some((exists) => exists);
};

export default pageFileExists;

We can use the return value of the above function to determine whether a page file exists or not.

TypeScript Support

With the evolution of JavaScript, we have TypeScript as a widely used way to write code and React components, and since this is a framework that was built in 2022, not supporting TypeScript would be criminal. Supporting TypeScript in our codebase is pretty straightforward.

We'll just extend our Babel configuration to support TypeScript as well.

npm i --save @babel/preset-typescript
{
    "presets": ["@babel/preset-env", "@babel/preset-react", "minify", "@babel/preset-typescript"]
    ...
}

On top of this, we'll have to add --extensions \".tsx,.js,.jsx,.ts,.json\" to our babel commands for TypeScript to work.

Putting it all together

After such a long post, I've compiled my library into a publicly available npm package that can be used to create Isomorphic React Apps, you can check the GitHub repository here and the npm package here that allows you to create isomorphic apps with a simple npx create-isomorph-app command.

There are n number of ways this library and the code I've mentioned above will break, and that's the point, this is a try at something much more complex that frameworks like Next.js and Remix solve, including a lot more than what this post covers.

Feel free to reach out to me in case you find any inconsistencies in the article. Just like all the amazing libraries and framework developers out there, even I am looking forward to constantly improving the quality of the code and content I deliver.