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.
In this post, 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.
getServerSideProps
and getStaticProps
along with a hook to get that data across the entire app at any component.404
and 500
errors.ISOMORPH_PUBLIC_
will be exposed to the client-side as well.To follow this post, and to also see a fully functional installable package, you can check out the corresponding Repository for Isomorph on GitHub.
Given this is a long post, here’s a link to the sections we’re convering in this post:
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
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:
*
, 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.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 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);
});
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>
);
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:
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";
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;
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;
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.
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):
_error
page present.// 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.
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.
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.