Building Isomorphic Applications with React.js and Node.js
Isomorphic applications, also known as universal applications, are web applications that can run both on the client-side and the server-side. They have become increasingly popular in recent years because they allow developers to write code that works seamlessly in both environments, resulting in improved performance, better SEO, and reduced code complexity. In this blog post, we'll explore how to build an isomorphic application using React.js and Node.js, two popular technologies for creating modern web applications. We'll cover the basics of setting up an isomorphic application, handling server-side rendering, and managing data fetching for both client and server environments. Let's get started!
Prerequisites
To follow along with this tutorial, you should have a basic understanding of JavaScript, React.js, and Node.js. Familiarity with Express.js, a popular Node.js web application framework, is also recommended.
Setting up the project
To get started, let's create a new project directory and initialize a new Node.js application:
mkdir isomorphic-react-node cd isomorphic-react-node npm init -y
Now, let's install the necessary dependencies:
npm install express react react-dom react-router-dom
We also need to install Babel and Webpack to transpile and bundle our code:
npm install --save-dev @babel/core @babel/preset-env @babel/preset-react babel-loader webpack webpack-cli webpack-node-externals
Next, create a webpack.config.js
file in the root of the project directory with the following configuration:
const path = require('path'); const nodeExternals = require('webpack-node-externals'); module.exports = { entry: './src/server.js', target: 'node', externals: [nodeExternals()], output: { path: path.resolve(__dirname, 'build'), filename: 'server.js' }, module: { rules: [ { test: /\.(js|jsx)$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env', '@babel/preset-react'] } } } ] }, resolve: { extensions: ['.js', '.jsx'] } };
Now, add a .babelrc
file in the root of the project directory with the following configuration:
{ "presets": ["@babel/preset-env", "@babel/preset-react"] }
Finally, update the scripts
section of your package.json
file as follows:
"scripts": { "build": "webpack", "start": "node build/server.js", "dev": "webpack --watch" }
Creating the Express server
First, create a src
folder in the root of the project directory, and inside it, create a new file called server.js
. This file will contain our Express server setup.
import express from 'express'; const app = express(); app.use(express.static('public')); app.listen(3000, () => { console.log('Server is running on port 3000'); });
Next, create a public
folder in the root of the project directory. This folder will hold our static assets.
Creating the React application
Inside the src
folder, create a new file called index.js
. This file will be the entry point for our React application. Add the following code:
import React from 'react'; import { render } from 'react-dom'; import App from './App'; render(<App />, document.getElementById('root'));
Next, createa new file called App.jsx
inside the src
folder. This file will contain our main React application component:
import React from 'react'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import Home from './components/Home'; import About from './components/About'; const App = () => { return ( <Router> <Switch> <Route exact path="/" component={Home} /> <Route path="/about" component={About} /> </Switch> </Router> ); }; export default App;
Now, let's create two simple components to demonstrate client-side routing: Home.jsx
and About.jsx
. Create a new folder called components
inside the src
folder, and add these two files:
Home.jsx
:
import React from 'react'; const Home = () => { return <div>Welcome to the Home page!</div>; }; export default Home;
About.jsx
:
import React from 'react'; const About = () => { return <div>Welcome to the About page!</div>; }; export default About;
Implementing server-side rendering
Now that we have a basic React application set up, let's implement server-side rendering. First, we need to modify our server.js
file to handle incoming requests and render the appropriate React components on the server.
Add the following imports to the top of your server.js
file:
import React from 'react'; import { renderToString } from 'react-dom/server'; import { StaticRouter } from 'react-router-dom'; import App from './App';
Next, replace the current app.use()
line with the following middleware function:
app.use((req, res) => { const context = {}; const appMarkup = renderToString( <StaticRouter location={req.url} context={context}> <App /> </StaticRouter> ); res.send(` <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Isomorphic React-Node Application</title> </head> <body> <div id="root">${appMarkup}</div> <script src="/bundle.js"></script> </body> </html> `); });
Here, we're using renderToString()
from react-dom/server
to render our App
component into a string. We're also using the StaticRouter
component from react-router-dom
to handle server-side routing.
Finally, update the Webpack configuration in your webpack.config.js
file to handle both server-side and client-side bundles:
const path = require('path'); const nodeExternals = require('webpack-node-externals'); const serverConfig = { // ... (existing server configuration) }; const clientConfig = { entry: './src/index.js', output: { path: path.resolve(__dirname, 'public'), filename: 'bundle.js' }, module: { rules: [ { test: /\.(js|jsx)$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env', '@babel/preset-react'] } } } ] }, resolve: { extensions: ['.js', '.jsx'] } }; module.exports = [serverConfig, clientConfig];
Now, update your scripts
section in thepackage.json
file to build both client-side and server-side bundles:
"scripts": { "build": "webpack", "start": "node build/server.js", "dev": "webpack --watch" }
With these changes, we have now implemented server-side rendering for our isomorphic React-Node application. When a user makes a request to our application, the server will render the appropriate React components and return the generated HTML.
Handling data fetching
One of the challenges of building isomorphic applications is handling data fetching in both client-side and server-side environments. To demonstrate how to handle data fetching, let's create a simple Posts
component that fetches and displays a list of posts.
Create a new file called Posts.jsx
inside the components
folder and add the following code:
import React, { useEffect, useState } from 'react'; const Posts = () => { const [posts, setPosts] = useState([]); useEffect(() => { fetch('/api/posts') .then((response) => response.json()) .then((data) => setPosts(data)); }, []); return ( <div> <h2>Posts</h2> <ul> {posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); }; export default Posts;
Next, update the App.jsx
file to include a new route for the Posts
component:
import React from 'react'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import Home from './components/Home'; import About from './components/About'; import Posts from './components/Posts'; const App = () => { return ( <Router> <Switch> <Route exact path="/" component={Home} /> <Route path="/about" component={About} /> <Route path="/posts" component={Posts} /> </Switch> </Router> ); }; export default App;
Now, let's create a simple API endpoint to fetch posts. In your server.js
file, add the following code before the existing app.use()
line:
app.get('/api/posts', (req, res) => { const posts = [ { id: 1, title: 'Hello, world!' }, { id: 2, title: 'React is awesome!' }, { id: 3, title: 'Building isomorphic apps with React and Node' }, ]; res.json(posts); });
At this point, our Posts
component will fetch and display the list of posts when accessed from the client-side. However, we need to make sure the data is fetched on the server-side as well, so the initial HTML sent to the client contains the posts.
To accomplish this, we'll introduce a new function called fetchInitialData()
in our Posts.jsx
file:
// ... export const fetchInitialData = () => { return fetch('/api/posts').then((response) => response.json()); }; const Posts = () => { // ... };
This function simply fetches the data and returns a Promise. Now, update your server.js
file to use this function and fetch the data on the server-side:
import { matchPath } from 'react-router-dom'; import App, { routes } from './App'; import { fetchInitialData } from './components/Posts'; // ... app.use(async (req, res) => { const promises = routes.reduce((acc, route) => { const match = matchPath(req.path, route); if (match && route.component.fetchInitialData) { acc.push(route.component.fetchInitialData()); } return acc; }, []); const data = await Promise.all(promises).then((responses) => responses[0]); const context = { data }; const appMarkup = renderToString( <StaticRouter location={req.url} context={context}> <App /> </StaticRouter> ); res.send(` <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Isomorphic React-Node Application</title> </head> <body> <div id="root">${appMarkup}</div> <script>window.__INITIAL_DATA__ = ${JSON.stringify(data).replace( /</g, '\\u003c' )}</script> <script src="/bundle.js"></script> </body> </html> `); });
Here, we're checking if the current route has a fetchInitialData
function, and if so, we're executing it and waiting for the data to be fetched. We then pass the fetched data to the context
object, which is used by our StaticRouter
. Finally, we're adding a script tag to the HTML output to make the initial data available to the client-side JavaScript.
Now, update the Posts.jsx
file to use the initial data if available:
import React, { useEffect, useState } from 'react'; const Posts = ({ initialData }) => { const [posts, setPosts] = useState(initialData || []); useEffect(() => { if (!initialData) { fetch('/api/posts') .then((response) => response.json()) .then((data) => setPosts(data)); } }, [initialData]); return ( // ... ); }; export default Posts;
Lastly, update the App.jsx
file to include the fetchInitialData
function in the route definition:
import React from 'react'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import Home from './components/Home'; import About from './components/About'; import Posts, { fetchInitialData as fetchPostsData } from './components/Posts'; const routes = [ { path: '/', component: Home, exact: true }, { path: '/about', component: About }, { path: '/posts', component: Posts, fetchInitialData: fetchPostsData }, ]; const App = () => { return ( <Router> <Switch> {routes.map((route) => ( <Route key={route.path} {...route} /> ))} </Switch> </Router> ); }; export { routes }; export default App;
With these changes, our application now fetches the data on the server-side and includes it in the initial HTML sent to the client. The client-side JavaScript then takes over and uses the initial data without making another API call.
Conclusion
In this tutorial, we've built an isomorphic React-Node application that supports server-side rendering and data fetching. We've used popular libraries such as Express, React, React Router, and Webpack to create a flexible and scalable application architecture.
Building isomorphic applications can help improve the performance of your web application, provide better SEO, and simplify your codebase by reusing components and logic between the client-side and server-side environments.
Sharing is caring
Did you like what Mehul Mohan wrote? Thank them for their work by sharing it on social media.
No comments so far
Curious about this topic? Continue your journey with these coding courses: