React 18 has introduced a lot of exciting changes and features. It’s probably something that you’ve heard a lot about already, and for good reasons. Even though slightly less eye-catching, there were also some extremely exciting developments made in the React SSR architecture. To understand the breakthroughs that React 18 brought, it’s essential to look at the entire timeline and the incremental steps that led up to it.
Before we can dive into the before-and-after of SSR, it’s crucial to understand the reason why we do SSR in the first place. In particular, we’ll dive into its importance and the aspects of it that shaped the way the React team decided to improve their SSR architecture.
In this article, we’ll be taking a careful look at SSR because it’s important to have a fundamental understanding of this topic and how it compares against other techniques, most importantly client-side rendering (CSR). Unfortunately, we can’t cover the entire thing in this article and will only focus on the important aspects of SSR in the context of React 18. Although not strictly necessary, we recommend you brush up on this topic to make the most out of this article.
Jump ahead:
At its core, the most important reasons for implementing SSR are:
In essence, there exists a specific rendering flow of a React application using SSR. First, the server takes over the client’s responsibility of fetching all the data and rendering the entire React application. After doing so, the resulting HTML and JavaScript are sent from the server to the client. Lastly, that client puts the HTML on the screen and connects it with appropriate JavaScript, which is also known as the hydration process. Now, the client receives the entire HTML structure instead of one enormous bundle of JavaScript that it needs to render itself.
The benefits of this flow include easier access for web crawlers to index these pages, which improves SEO, and the client can quickly show the generated HTML to the user instead of a blank screen, which improves UX. Because all the rendering happens on the server, the client is relieved of this duty and doesn’t risk becoming a bottleneck in the scenario of low-end devices, leading to improved performance.
However, the setup as described is only a starting point for SSR. Based on how things are implemented above, there is a lot more to gain in terms of performance and UX. With these two aspects in mind, let’s take a trip down React SSR memory lane, dive into the issues pre-React 18, experience its evolution over time, and learn how React 18 with its streaming features changed everything.
Before React 18, Suspense, or any of the new streaming features existed, the typical SSR setup in React would look something as follows. While different implementations will probably contain minor differences, most setups will follow a similar architecture.
// server/index.ts import path from 'path'; import fs from 'fs'; import React from 'react'; import ReactDOMServer from 'react-dom/server'; import express from 'express'; import { App } from '../client/App'; const app = express(); app.get('/', (req, res) => { const appContent = ReactDOMServer.renderToString(<App />); const indexFile = path.resolve('./build/index.html'); fs.readFile(indexFile, 'utf8', (err, data) => { if (err) { console.error('Something went wrong:', err); return res.status(500).send('Failed to load the app.'); } return res.send( data.replace('<div id="root"></div>', `<div id="root">${app}</div>`) ); }); }); app.use(express.static('./build')); app.listen(8080, () => { console.log(`Server is listening on port ${PORT}`); }); // build/index.html <!DOCTYPE html> <html lang="en"> <head> <title>React App</title> <script src="main.js" async defer></script> </head> <body> <div id="root"></div> </body> </html>
The biggest part of an SSR setup is the server, so let’s start with that. In this example, we’re using Express to spin up a server to serve the files from our build
folder on port 8080. When the server receives a request at the root URL, it will render the React application to an HTML string using the renderToString
function from the ReactDOMServer
package.
The result then needs to be sent back to the client. But before that, the server needs to surround the rendered application with the appropriate HTML structure. To do so, this example looks in the build
folder for the index.html
file, imports it, and injects the rendered application into the root element:
// client/index.ts import React from "react"; import ReactDOM from 'react-dom'; import { App } from './App'; // Instead of `ReactDOM.render(...)` ReactDOM.hydrate(<App />, document.getElementById('root'));
Then, the main change that needs to be made on the client side is that it doesn’t have to render the application anymore.
As we saw in the previous step, the application is already rendered by the server. So now, the client is only responsible for hydrating the application. It does so by using the ReactDOM.hydrate
function instead of ReactDOM.render
.
While this is a working setup of React SSR, there are still a few major drawbacks to it regarding performance and UX:
In the end, all of these drawbacks boil down to the current setup, which is still a waterfall-like approach from the server towards the client. This creates an all-or-nothing flow from one end to the other: either the entire HTML response is sent to the client or not, either all the data is done fetching so the server can start rendering or not, either the entire application is hydrated or not, and either the entire page is responsive or none.
In React 16, the renderToNodeStream server rendering function
was introduced on top of the existing renderToString
. In terms of the setup and results, this didn’t change a lot except that this function returns a Node.js ReadableStream
. This allows the server to stream the HTML to the client.
app.get('/', (req, res) => { const endHTML = "</div></body></html>"; const indexFile = path.resolve('./build/index.html'); fs.readFile(indexFile, 'utf8', (err, data) => { if (err) { console.error('Something went wrong:', err); return res.status(500).send('Failed to load the app.'); } // Split the HTML where the React app should be injected and send the first part to the client const beginHTML = data.replace('</div></body></html>', ''); res.write(beginHTML); // Render the application into a stream using `renderToNodeStream` and pipe that into the response const appStream = ReactDOMServer.renderToNodeStream(<App />); appStream.pipe(res, { end: 'false' }); // When the server is done rendering, send the rest of the HTML appStream.on('end', () => { response.end(endHTML); )}; }); });
This new function partly solves one of the drawbacks we described; namely, it has to transmit the HTML response as one large blob from the server to the client. However, the server still needs to wait for the entire HTML structure to be generated before it can start transmitting anything to the client. So, it doesn’t really address any of the other drawbacks that we described.
Now, let’s look at the situation after React 18 with the newly introduced features and how they address these drawbacks.
The SSR architecture post-React 18 involves a handful of different parts. None of these single-handedly fixes any of the drawbacks that we described, but the combination of them makes the magic work. So, to fully understand the entire setup, it’s necessary to look into all of them and what they contribute.
Suspense
componentAt the center of it all is the famous Suspense component
. It is the main gateway towards all the features that we’ll describe, so let’s start with it.
// client/src/SomeComponent.js import { lazy, Suspense } from 'react'; const SomeInnerComponent = lazy(() => import('./SomeInnerComponent.js' /* webpackPrefetch: true */)); export default function SomeComponent() { // ... return ( <Suspense fallback={<Spinner />}> <SomeInnerComponent /> </Suspense> ); }
In short, Suspense is a mechanism for developers to tell React that a certain part of the application is waiting for data. In the meantime, React will show a fallback UI in its place and update it when the data is ready.
This doesn’t sound too different from previous approaches, but fundamentally, it synchronizes React’s rendering process and the data-fetching process in a way that is more graceful and integrated. To learn more about the details, take a look at this guide to Suspense.
Suspense boundaries split up the application into chunks based on their data fetching requirements, which the server can then use to delay rendering what is pending. Meanwhile, it can pre-render the chunks for which data is available and stream it to the client. When the data for a previously pending chunk is ready, the server will then render it and send it to the client again using the open stream.
Together with React.lazy
, which is used to code-split your JavaScript bundle into smaller parts, it provides the first pieces of the puzzle towards fixing the remaining waterfall drawbacks.
However, the problem was that Suspense and code-splitting using React.lazy
were not compatible with SSR yet, until React 18.
renderToPipeableStream
APITo understand the remaining connecting puzzle pieces, we’ll take a look at the Suspense SSR example that the React teams provided in their working group discussion for the architecture post React 18.
import ReactDOMServer from "react-dom/server"; import { App } from "../client/App"; app.get('/', (req, res) => { res.socket.on('error', (error) => console.log('Fatal', error)); let didError = false; const stream = ReactDOMServer.renderToPipeableStream( <App />, { bootstrapScripts: ['/main.js'], onShellReady: () => { res.statusCode = didError ? 500 : 200; res.setHeader('Content-type', 'text/html'); stream.pipe(res); }, onError: (error) => { didError = true; console.log(error); } } ); });
The most significant change compared to the previous setup is the usage of renderToPipeableStream
API on the server side. This is a newly introduced server rendering function in React 18, which returns a pipeable Node.js stream. While the previous renderToNodeStream
couldn’t wait for data and would buffer the entire HTML content until the end of the stream, the renderToPipeableStream
function does not suffer from these limitations.
When the content above the Suspense
boundary is ready, the onShellReady
callback is called. If any error happened in the meanwhile, it’ll be reflected that in the response towards the client. Then, we’ll start streaming the HTML to the client by piping it into the response.
After that, the stream will stay open and transmit any subsequent rendered HTML blocks to the client. This is the biggest change compared to its former version.
This rendering function fully integrates with the Suspense feature and code splitting through React.lazy
on the side of the server, which is what enables the streaming HTML feature for SSR. This solves the previously described waterfalls of both HTML and data fetching, as the application can be rendered and transmitted incrementally based on data requirements.
// client/index.ts import React from "react"; import ReactDOMClient from 'react-dom/client'; import { App } from './App'; // Instead of `ReactDOM.hydrate(...)` ReactDOMClient.hydrateRoot(document.getElementById('root'), <App />);
ReactDOMClient.hydrateRoot
for selective hydrationOn the client side, the only change that needs to be made is how the application is put on the screen. As a replacement for the previous ReactDOM.hydrate
, the React team has introduced a new ReactDOMClient.hydrateRoot
in React 18. While the change is minimal, it enables a lot of improvements and features. For our context, the most important one is selective hydration.
As mentioned, Suspense splits the application into HTML chunks based on data requirements, while code-splitting splits the application into JavaScript chunks. Selective hydration allows React to put these things together on the client and start hydrating chunks at different timings and priorities. It can start hydrating as soon as chunks of HTML and JS are received, and prioritize a hydration queue of parts that the user interacted with.
This solves the remaining two waterfall issues that we had: having to wait for all JavaScript to load before hydrating can start, and either hydrating the entire application or none of it.
The combination of selective hydration and the other mentioned features allows React to start hydrating as soon as the necessary JavaScript code is loaded, while also being able to hydrate different parts of the application separately and based on priority.
React 18 is the cherry on top of the long-lasting development of changes in its SSR architecture over several major versions and years of fine-tuning. Suspense and code splitting were early pieces of the puzzle, but couldn’t be used to their full potential on the server until the introduction of streaming HTML and selective hydration in React 18.
To help you understand how these changes came to fruition, we looked at the situation before and after React 18, explored code examples of typical approaches to setting SSR up, dove into the main drawbacks that the React SSR architecture faced, and lastly went over how the combination of Suspense, code splitting, streaming HTML, and selective hydration have solved these issues.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]