Loading...

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.

0/10000

No comments so far