In This post we will implement server-side rendering and UI streaming in React from scratch using express.js as our server framework.
One of the most exciting features of Next.js is UI Streaming, which enables us to show partial content alongside an instant loading state for the parts that still need data to be fetched directly from the server. While it’s easy to use streaming in Next.js, it’s still a good idea to try implementing our own version of streaming. So, let’s get started.
Make sure to visit this post’s github repository. Please consider following this project’s author, Sina Bayandorian, and starring the project to show your ❤️ and support. https://github.com/sina-byn/react-streaming
Table of Contents
Initial Setup
$ mkdir react-streaming
$ cd react-streaming
$ mkdir src
$ npm init -y
$ touch src/server.js
Now let’s install our dependencies:
$ npm i --save express dotenv chalk@4.1.2
$ npm i -D @babel/core @babel/node @babel/preset-env @babel/preset-react
Configure babel .babelrc
:
{
"ignore": ["node_modules"],
"presets": [
["@babel/preset-env", {
"targets": {
"node": "current"
}
}], "@babel/preset-react"
]
}
Initializing our Express server:
// src/server.js
import chalk from 'chalk';
import express from 'express';
import { config as env } from 'dotenv';
// * initialization
env();
const app = express();
const port = process.env.PORT ?? 3000;
app.use(express.static('public'));
app.get('/', (_req, res) => {
res.status(200).json({ message: "Success" })
});
app.listen(port, () => {
console.log(chalk.blueBright(`Server running at http://localhost:${port}`));
});
In order to server-side render our React application, we need the renderToPipeableStream
function from react-dom/server
. So, let’s install it alongside react
and create some necessary components:
$ npm i react react-dom
$ mkdir src/client
$ touch src/client/App.jsx
$ touch src/client/Html.jsx
// src/client/App.jsx
import * as React from 'react';
const App = () => {
return (<div>This is our React application</div>);
};
export default App;
// src/client/Html.jsx
import * as React from 'react';
// * initial HTML boilerplate
const Html = ({ children }) => {
return (
<html>
<head>
<meta charSet='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>Document</title>
</head>
<body>
<div id='app'>{children}</div>
</body>
</html>
);
};
export default Html;
Next, we will add server-side rendering logic to our Express server:
// src/server.js
import * as React from 'react';
import { renderToPipeableStream } from 'react-dom/server';
// * components
import Html from './client/Html';
import App from './client/App';
app.get('/', (_req, res) => {
const stream = renderToPipeableStream(
<Html>
<App />
</Html>,
{
onShellReady() {
stream.pipe(res);
},
}
);
});
Add an NPM script to start the server:
// package.json
{
"scripts": {
"start:server": "npx babel-node src/server.js"
}
}
$ npm run start:server
Open http//:localhost:3000
in your browser and view the page source, you will see that your React app is successfully rendered on the server.
Now we have a React app that is server-side rendered, but we have two major problems:
- Our React app doesn’t have any interactivity.
- We are not exactly streaming the components even though we are using
renderToPipeableStream
.
Let’s first address the interactivity issue.
Interactivity
There are two steps involved in creating a server-side rendered React app:
- Render the app on the server and serve it.
- Have React hydrate the server-side HTML and add interactivity.
To allow React to hydrate the app, we use the hydarteRoot
function provided by react-dom/client
to have React take over the control once server is done serving the HTML.
To achieve this, we utilize the hydrateRoot
function provided by react-dom/client
, allowing React to take control once the server has finished serving the HTML — the initial HTML in case of streaming.
So let’s create index.jsx
:
$ touch src/client/index.jsx
// src/client/index.jsx
import * as React from 'react';
import { hydrateRoot } from 'react-dom/client';
// * components
import App from './App';
hydrateRoot(document.getElementById('app'), <App />);
Now we need a way to transpile and bundle index.jsx
so that it can be included in the HTML sent from the server. For this we use webpack
and babel-loader
:
$ npm i -D webpack webpack-cli babel-loader
// webpack.config.js
const path = require('path');
module.exports = {
entry: './src/client/index.jsx',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'public'),
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
],
},
resolve: {
extensions: ['.js', '.jsx'],
},
};
{
"scripts": {
"build:client": "npx webpack --mode production",
"start:server": "npx babel-node src/server.js",
"start": "npm run build:client && npm run start:server"
}
}
Then run $ npm run build:client
to have webpack generate the bundle.js
inside the public
folder.
The final step to add interactivity involves including bundle.js
within the HTML generated on the server, which is quite easy thanks to React. Simply include the script file in the bootstrapScripts
field of the renderToPipeableStream
function options.
app.get('/', (_req, res) => {
const stream = renderToPipeableStream(
<Html>
<App />
</Html>,
{
bootstrapScripts: ['/bundle.js'],
onShellReady() {
stream.pipe(res);
},
}
);
});
Let’s check the interactivity:
$ touch src/client/Counter.jsx
// src/client/Counter.jsx
import * as React from 'react';
const Counter = () => {
const [count, setCount] = React.useState(0);
const decrement = () => setCount(prev => prev - 1);
const increment = () => setCount(prev => prev + 1);
return (
<div className='counter' style={{ display: 'flex', gap: '0.5rem' }}>
<button type='button' onClick={decrement}>
decrement
</button>
{count}
<button type='button' onClick={increment}>
increment
</button>
</div>
);
};
export default Counter;
// src/client/App.jsx
import * as React from 'react';
// * components
import List from './List';
import Counter from './Counter';
const App = () => {
return (
<main>
<p style={{ marginBottom: '1rem' }}>this is the app component</p>
<Counter />
<React.Suspense
fallback={
<div style={{ marginTop: '1rem' }}>Loading the List...</div>
}
>
<List />
</React.Suspense>
</main>
);
};
export default App;
Now start the project with $ npm run start
and check the counter. You’ll see that it’s fully interactive.
Streaming
The last step of this tutorial is to ensure that we stream the components needing data and display loading states while waiting for the data to be fetched.
With this setup, we can use React Suspense boundaries to show loading states using the fallback
prop, but the problem is that React children can not be promises and the value of a pending fetch request is a Promise
. So, we will end up with an error. To solve this, we use React’s experimental use
hook to handle the promise, which is only available under React’s canary release channel.
In order to use this hook we first need to update both react
and react-dom
to the canaray version:
$ npm install react@canary react-dom@canary
What we need next is a component to be streamed:
$ touch src/client/List.jsx
// src/client/List.jsx
import * as React from 'react';
// * data
const list = [
{
userId: 1,
id: 1,
title: 'sunt aut facere repellat provident occaecati excepturi optio',
body: 'quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto',
},
{
userId: 1,
id: 2,
title: 'qui est esse',
body: 'est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla',
},
{
userId: 1,
id: 3,
title: 'ea molestias quasi exercitationem repellat qui ipsa sit aut',
body: 'et iusto sed quo iure\nvoluptatem occaecati omnis eligendi aut ad\nvoluptatem doloribus vel accusantium quis pariatur\nmolestiae porro eius odio et labore et velit aut',
},
];
const fetchList = () => {
return new Promise(resolve => setTimeout(() => resolve(list), 3000));
};
const List = () => {
const [items] = React.useState(React.use(fetchList()));
return (
<div
className='list'
style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}
>
{items.map(item => (
<div
key={item.id}
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
>
<h3>{item.title}</h3>
<p>{item.body}</p>
</div>
))}
</div>
);
};
export default List;
Now, we should wrap the List
component with a Suspense
boundary:
// src/client/App.jsx
import * as React from 'react';
// * components
import List from './List';
const App = () => {
return (
<main>
this is the app component
<React.Suspense fallback={<div>Loading the List...</div>}>
<List />
</React.Suspense>
</main>
);
};
export default App;
$ npm run start
Open http://localhost:3000
wait for 3 seconds and ta-da!
Now you have a working server-side rendered React application that supports UI streaming and instant loading states.
Make sure to visit this post’s github repository. Please consider following this project’s author, Sina Bayandorian, and starring the project to show your ❤️ and support. https://github.com/sina-byn/react-streaming
Additional Resources
- react.dev —
use
hook - react.dev —
hydrateRoot
- react.dev —
renderToPipeableStream
- Next.js — Loading UI and Streaming
- Jack Herrington — React Streaming In Depth: NextJS! Remix! DIY!