React SSR
React is very popular JavaScript web UI framework. A React application is “compiled” into an HTML and JavaScript static web site. The web UI is rendered through the generated JavaScript code. However, it is often too slow and resource consuming to execute the complex generated JavaScript entirely in the browser to build the interactive HTML DOM objects. React Server Side Rendering (SSR) delegates the JavaScript UI rendering to a server, and have the server stream rendered HTML DOM objects to the browser. The WasmEdge JavaScript runtime provides a lightweight and high performance container to run React SSR functions on edge servers.
Server-side rendering (SSR) is a popular technique for rendering a client-side single page application (SPA) on the server and then sending a fully rendered page to the client. This allows for dynamic components to be served as static HTML markup. This approach can be useful for search engine optimization (SEO) when indexing does not handle JavaScript properly. It may also be beneficial in situations where downloading a large JavaScript bundle is impaired by a slow network. — from Digital Ocean.
In this article, we will show you how to use the WasmEdge QuickJS runtime to implement a React SSR function. Compared with the Docker + Linux + nodejs + v8 approach, WasmEdge is much lighter (1% of the footprint) and safer, provides better resource isolation and management, and has similar non-JIT (safe) performance.
We will start from a complete tutorial to create and deploy a simple React SSR web application from the standard template. Then, we will discuss two modes of SSR: static and streaming rendering. Static rendering is easy to understand and implement. Streaming rendering, on the other hand, provides better user experience since the user can see partial results while waiting in front of the browser. We will walk through the key code snippets for both static and streaming rendering.
- Getting started
- Static rendering code example [GitHub Action | Log]
- Streaming rendering code example [GitHub Action | Log]
- A complete React 18 example [GitHub Action | Log]
Getting started
Step 1 — Create the React App
First, use npx
to create a new React app. Let’s name the app react-ssr-example
.
npx create-react-app react-ssr-example
Then, cd
into the directory for the newly created app.
cd react-ssr-example
Start the new app in order to verify the installation.
npm start
You should see the example React app displayed in your browser window. At this stage, the app is rendered in the browser. The browser runs the generated React JavaScript to build the HTML DOM UI.
Now in order to prepare for SSR, you will need to make some changes to the app’s index.js
file. Change ReactDOM’s render
method to hydrate
to indicate to the DOM renderer that you intend to rehydrate the app after it is rendered on the server. Replace the contents of the index.js
file with the following.
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.hydrate(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
Note: you should import React
redundantly in the src/App.js
, so the server will recognize it.
import React from 'react';
//...
That concludes setting up the application, you can move on to setting up the server-side rendering functions.
Step 2 — Create an WasmEdge QuickJS Server and Render the App Component
Now that you have the app in place, let’s set up a server that will render the HTML DOM by running the React JavaScript and then send the rendered elements to the browser. We will use WasmEdge as a secure, high-performance and lightweight container to run React JavaScript.
Create a new server
directory in the project’s root directory.
mkdir server
Then, inside the server
directory, create a new index.js
file with the server code.
import * as React from 'react';
import ReactDOMServer from 'react-dom/server';
import * as std from 'std';
import * as http from 'wasi_http';
import * as net from 'wasi_net';
import App from '../src/App.js';
async function handle_client(cs) {
print('open:', cs.peer());
let buffer = new http.Buffer();
while (true) {
try {
let d = await cs.read();
if (d == undefined || d.byteLength <= 0) {
return;
}
buffer.append(d);
let req = buffer.parseRequest();
if (req instanceof http.WasiRequest) {
handle_req(cs, req);
break;
}
} catch (e) {
print(e);
}
}
print('end:', cs.peer());
}
function enlargeArray(oldArr, newLength) {
let newArr = new Uint8Array(newLength);
oldArr && newArr.set(oldArr, 0);
return newArr;
}
async function handle_req(s, req) {
print('uri:', req.uri)
let resp = new http.WasiResponse();
let content = '';
if (req.uri == '/') {
const app = ReactDOMServer.renderToString(<App />);
content = std.loadFile('./build/index.html');
content = content.replace('<div id="root"></div>', `<div id="root">${app}</div>`);
} else {
let chunk = 1000; // Chunk size of each reading
let length = 0; // The whole length of the file
let byteArray = null; // File content as Uint8Array
// Read file into byteArray by chunk
let file = std.open('./build' + req.uri, 'r');
while (true) {
byteArray = enlargeArray(byteArray, length + chunk);
let readLen = file.read(byteArray.buffer, length, chunk);
length += readLen;
if (readLen < chunk) {
break;
}
}
content = byteArray.slice(0, length).buffer;
file.close();
}
let contentType = 'text/html; charset=utf-8';
if (req.uri.endsWith('.css')) {
contentType = 'text/css; charset=utf-8';
} else if (req.uri.endsWith('.js')) {
contentType = 'text/javascript; charset=utf-8';
} else if (req.uri.endsWith('.json')) {
contentType = 'text/json; charset=utf-8';
} else if (req.uri.endsWith('.ico')) {
contentType = 'image/vnd.microsoft.icon';
} else if (req.uri.endsWith('.png')) {
contentType = 'image/png';
}
resp.headers = {
'Content-Type': contentType
};
let r = resp.encode(content);
s.write(r);
}
async function server_start() {
print('listen 8002...');
try {
let s = new net.WasiTcpServer(8002);
for (var i = 0; ; i++) {
let cs = await s.accept();
handle_client(cs);
}
} catch (e) {
print(e);
}
}
server_start();
The server renders the <App>
component, and then sends the rendered HTML string back to the browser. Three important things are taking place here.
- ReactDOMServer’s
renderToString
is used to render the<App/>
to an HTML string. - The
index.html
file from the app’sbuild
output directory is loaded as a template. The app’s content is injected into the<div>
element with an id of"root"
. It is then sent back as HTTP response. - Other files from the
build
directory are read and served as needed at the requests of the browser.
Step 3 — Build and deploy
For the server code to work, you will need to bundle and transpile it. In this section, we will show you how to use webpack and Babel. In this next section, we will demonstrate an alternative (and potentially easier) approach using rollup.js.
Create a new Babel configuration file named .babelrc.json
in the project’s root directory and add the env
and react-app
presets.
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}
Create a webpack config for the server that uses Babel Loader to transpile the code. Start by creating the webpack.server.js
file in the project’s root directory.
const path = require('path');
module.exports = {
entry: './server/index.js',
externals: [
{"wasi_http": "wasi_http"},
{"wasi_net": "wasi_net"},
{"std": "std"}
],
output: {
path: path.resolve('server-build'),
filename: 'index.js',
chunkFormat: "module",
library: {
type: "module"
},
},
experiments: {
outputModule: true
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader'
},
{
test: /\.css$/,
use: ["css-loader"]
},
{
test: /\.svg$/,
use: ["svg-url-loader"]
}
]
}
};
With this configuration, the transpiled server bundle will be output to the server-build
folder in a file called index.js
.
Next, add the svg-url-loader
package by entering the following commands in your terminal.
npm install svg-url-loader --save-dev
This completes the dependency installation and webpack and Babel configuration.
Now, revisit package.json
and add helper npm scripts. Add dev:build-server
, dev:start-server
scripts to the package.json
file to build and serve the SSR application.
"scripts": {
"dev:build-server": "NODE_ENV=development webpack --config webpack.server.js --mode=development",
"dev:start-server": "wasmedge --dir .:. wasmedge_quickjs.wasm ./server-build/index.js",
// ...
},
- The
dev:build-server
script sets the environment to"development"
and invokes webpack with the configuration file you created earlier. - The
dev:start-server
script runs the WasmEdge server from thewasmedge
CLI tool to serve the built output. Thewasmedge_quickjs.wasm
program contains the QuickJS runtime. Learn more
Now you can run the following commands to build the client-side app, bundle and transpile the server code, and start up the server on :8002
.
npm run build
npm run dev:build-server
npm run dev:start-server
Open http://localhost:8002/
in your web browser and observe your server-side rendered app.
Previously, the HTML source in the browser is simply the template with SSR placeholders.
Output
<div id="root"></div>
Now, with the SSR function running on the server, the HTML source in the browser is as follows.
Output
<div id="root"><div class="App" data-reactroot="">...</div></div>
Step 4 (alternative) — build and deploy with rollup.js
Alternatively, you could use the rollup.js tool to package all application components and library modules into a single file for WasmEdge to execute.
Create a rollup config for the server that uses Babel Loader to transpile the code. Start by creating the rollup.config.js
file in the project’s root directory.
const {babel} = require('@rollup/plugin-babel');
const nodeResolve = require('@rollup/plugin-node-resolve');
const commonjs = require('@rollup/plugin-commonjs');
const replace = require('@rollup/plugin-replace');
const globals = require('rollup-plugin-node-globals');
const builtins = require('rollup-plugin-node-builtins');
const plugin_async = require('rollup-plugin-async');
const css = require("rollup-plugin-import-css");
const svg = require('rollup-plugin-svg');
const babelOptions = {
babelrc: false,
presets: [
'@babel/preset-react'
],
babelHelpers: 'bundled'
};
module.exports = [
{
input: './server/index.js',
output: {
file: 'server-build/index.js',
format: 'esm',
},
external: [ 'std', 'wasi_net','wasi_http'],
plugins: [
plugin_async(),
babel(babelOptions),
nodeResolve({preferBuiltins: true}),
commonjs({ignoreDynamicRequires: false}),
css(),
svg({base64: true}),
globals(),
builtins(),
replace({
preventAssignment: true,
'process.env.NODE_ENV': JSON.stringify('production'),
'process.env.NODE_DEBUG': JSON.stringify(''),
}),
],
},
];
With this configuration, the transpiled server bundle will be output to the server-build
folder in a file called index.js
.
Next, add the dependent packages to the package.json
then install with npm
.
"devDependencies": {
//...
"@rollup/plugin-babel": "^5.3.0",
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-node-resolve": "^7.1.3",
"@rollup/plugin-replace": "^3.0.0",
"rollup": "^2.60.1",
"rollup-plugin-async": "^1.2.0",
"rollup-plugin-import-css": "^3.0.3",
"rollup-plugin-node-builtins": "^2.1.2",
"rollup-plugin-node-globals": "^1.4.0",
"rollup-plugin-svg": "^2.0.0"
}
npm install
This completes the dependency installation and rollup configuration.
Now, revisit package.json
and add helper npm scripts. Add dev:build-server
, dev:start-server
scripts to the package.json
file to build and serve the SSR application.
"scripts": {
"dev:build-server": "rollup -c rollup.config.js",
"dev:start-server": "wasmedge --dir .:. wasmedge_quickjs.wasm ./server-build/index.js",
// ...
},
- The
dev:build-server
script sets the environment to"development"
and invokes webpack with the configuration file you created earlier. - The
dev:start-server
script runs the WasmEdge server from thewasmedge
CLI tool to serve the built output. Thewasmedge_quickjs.wasm
program contains the QuickJS runtime. Learn more
Now you can run the following commands to build the client-side app, bundle and transpile the server code, and start up the server on :8002
.
npm run build
npm run dev:build-server
npm run dev:start-server
Open http://localhost:8002/
in your web browser and observe your server-side rendered app.
Previously, the HTML source in the browser is simply the template with SSR placeholders.
Output
<div id="root"></div>
Now, with the SSR function running on the server, the HTML source in the browser is as follows.
Output
<div id="root"><div class="App" data-reactroot="">...</div></div>
In the next two sections, we will dive deeper into the source code for two ready-made sample applications for static and streaming rendering of SSR.
Static rendering
The example_js/react_ssr folder in the GitHub repo contains the example’s source code. It showcases how to compose HTML templates and render them into an HTML string in a JavaScript app running in WasmEdge.
The component/Home.jsx file is the main page template in React.
import React from 'react';
import Page from './Page.jsx';
class Home extends React.Component {
render() {
const { dataList = [] } = this.props;
return (
<div>
<div>This is home</div>
<Page></Page>
</div>
);
}
};
export default Home;
The Home.jpx
template includes a Page.jpx template for part of the page.
import React from 'react';
class Page extends React.Component {
render() {
const { dataList = [] } = this.props;
return (
<div>
<div>This is page</div>
</div>
);
}
};
export default Page;
The main.js file calls React to render the templates into HTML.
import React from 'react';
import {renderToString} from 'react-dom/server';
import Home from './component/Home.jsx';
const content = renderToString(React.createElement(Home));
console.log(content);
The rollup.config.js and package.json files are to build the React SSR dependencies and components into a bundled JavaScript file for WasmEdge. You should use the npm
command to build it. The output is in the dist/main.js
file.
npm install
npm run build
To run the example, do the following on the CLI. You can see that the templates are successfully composed into an HTML string.
$ cd example_js/react_ssr
$ wasmedge --dir .:. ../../target/wasm32-wasi/release/wasmedge_quickjs.wasm dist/main.js
<div data-reactroot=""><div>This is home</div><div><div>This is page</div></div></div>
Note: the
--dir .:.
on the command line is to give wasmedge permission to read the local directory in the file system for thedist/main.js
file.
Streaming rendering
The example_js/react_ssr_stream folder in the GitHub repo contains the example’s source code. It showcases how to streaming render an HTML string from templates in a JavaScript app running in WasmEdge.
The component/LazyHome.jsx file is the main page template in React. It “lazy” loads the inner page template after a 2s delay once the outer HTML is rendered and returned to the user.
import React, { Suspense } from 'react';
import * as LazyPage from './LazyPage.jsx';
async function sleep(ms) {
return new Promise((r, _) => {
setTimeout(() => r(), ms)
});
}
async function loadLazyPage() {
await sleep(2000);
return LazyPage
}
class LazyHome extends React.Component {
render() {
let LazyPage1 = React.lazy(() => loadLazyPage());
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<title>Title</title>
</head>
<body>
<div>
<div> This is LazyHome </div>
<Suspense fallback={<div> loading... </div>}>
<LazyPage1 />
</Suspense>
</div>
</body>
</html>
);
}
}
export default LazyHome;
The LazyPage.jsx is the inner page template. It is rendered 2s after the outer page is already returned to the user.
import React from 'react';
class LazyPage extends React.Component {
render() {
return (
<div>
<div>
This is lazy page
</div>
</div>
);
}
}
export default LazyPage;
The main.mjs file starts a non-blocking HTTP server, and then renders the HTML page in multiple chuncks to the response. When a HTTP request comes in, the handle_client()
function is called to render the HTML and to send back the results through the stream.
import * as React from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import * as http from 'wasi_http';
import * as net from 'wasi_net';
import LazyHome from './component/LazyHome.jsx';
async function handle_client(s) {
let resp = new http.WasiResponse();
resp.headers = {
"Content-Type": "text/html; charset=utf-8"
}
renderToPipeableStream(<LazyHome />).pipe(resp.chunk(s));
}
async function server_start() {
print('listen 8001...');
let s = new net.WasiTcpServer(8001);
for (var i = 0; i < 100; i++) {
let cs = await s.accept();
handle_client(cs);
}
}
server_start();
The rollup.config.js and package.json files are to build the React SSR dependencies and components into a bundled JavaScript file for WasmEdge. You should use the npm
command to build it. The output is in the dist/main.mjs
file.
npm install
npm run build
To run the example, do the following on the CLI to start the server.
cd example_js/react_ssr_stream
nohup wasmedge --dir .:. ../../target/wasm32-wasi/release/wasmedge_quickjs.wasm dist/main.mjs &
Send the server a HTTP request via curl
or the browser.
curl http://localhost:8001
The results are as follows. The service first returns an HTML page with an empty inner section (i.e., the loading
section), and then 2s later, the HTML content for the inner section and the JavaScript to display it.
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
100 211 0 211 0 0 1029 0 --:--:-- --:--:-- --:--:-- 1024
100 275 0 275 0 0 221 0 --:--:-- 0:00:01 --:--:-- 220
100 547 0 547 0 0 245 0 --:--:-- 0:00:02 --:--:-- 245
100 1020 0 1020 0 0 413 0 --:--:-- 0:00:02 --:--:-- 413
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><title>Title</title></head><body><div><div> This is LazyHome </div><!--$?--><template id="B:0"></template><div> loading... </div><!--/$--></div></body></html><div hidden id="S:0"><template id="P:1"></template></div><div hidden id="S:1"><div><div>This is lazy page</div></div></div><script>function $RS(a,b){a=document.getElementById(a);b=document.getElementById(b);for(a.parentNode.removeChild(a);a.firstChild;)b.parentNode.insertBefore(a.firstChild,b);b.parentNode.removeChild(b)};$RS("S:1","P:1")</script><script>function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}};$RC("B:0","S:0")</script>
React 18 SSR
In this section, we will demonstrate a complete React 18 SSR application. It renders the web UI through streaming SSR. The example_js/react18_ssr folder in the GitHub repo contains the example’s source code. The component folder contains the entire React 18 application’s source code, and the public folder contains the public resources (CSS and images) for the web application.
The main.mjs file starts a non-blocking HTTP server, maps the main.css
and main.js
files in the public
folder to web URLs, and then renders the HTML page for each request in renderToPipeableStream()
.
import * as React from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import * as http from 'wasi_http';
import * as net from 'wasi_net';
import * as std from 'std';
import App from './component/App.js';
import { DataProvider } from './component/data.js'
let assets = {
'main.js': '/main.js',
'main.css': '/main.css',
};
const css = std.loadFile('./public/main.css')
function createServerData() {
let done = false;
let promise = null;
return {
read() {
if (done) {
return;
}
if (promise) {
throw promise;
}
promise = new Promise(resolve => {
setTimeout(() => {
done = true;
promise = null;
resolve();
}, 2000);
});
throw promise;
},
};
}
async function handle_client(cs) {
print('open:', cs.peer());
let buffer = new http.Buffer();
while (true) {
try {
let d = await cs.read();
if (d == undefined || d.byteLength <= 0) {
return;
}
buffer.append(d);
let req = buffer.parseRequest();
if (req instanceof http.WasiRequest) {
handle_req(cs, req);
break;
}
} catch (e) {
print(e);
}
}
print('end:', cs.peer());
}
async function handle_req(s, req) {
print('uri:', req.uri)
let resp = new http.WasiResponse();
if (req.uri == '/main.css') {
resp.headers = {
"Content-Type": "text/css; charset=utf-8"
}
let r = resp.encode(css);
s.write(r);
} else {
resp.headers = {
"Content-Type": "text/html; charset=utf-8"
}
let data = createServerData()
renderToPipeableStream(
<DataProvider data={data}>
<App assets={assets} />
</DataProvider>
).pipe(resp.chunk(s));
}
}
async function server_start() {
print('listen 8002...');
try {
let s = new net.WasiTcpServer(8002);
for (var i = 0; ; i++) {
let cs = await s.accept();
handle_client(cs);
}
} catch (e) {
print(e)
}
}
server_start();
The rollup.config.js and package.json files are to build the React 18 SSR dependencies and components into a bundled JavaScript file for WasmEdge. You should use the npm
command to build it. The output is in the dist/main.mjs
file.
npm install
npm run build
To run the example, do the following on the CLI to start the server.
cd example_js/react_ssr_stream
nohup wasmedge --dir .:. ../../target/wasm32-wasi/release/wasmedge_quickjs.wasm dist/main.mjs &
Send the server a HTTP request via curl
or the browser.
curl http://localhost:8002
The results are as follows. The service first returns an HTML page with an empty inner section (i.e., the loading
section), and then 2s later, the HTML content for the inner section and the JavaScript to display it.
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
100 439 0 439 0 0 1202 0 --:--:-- --:--:-- --:--:-- 1199
100 2556 0 2556 0 0 1150 0 --:--:-- 0:00:02 --:--:-- 1150
100 2556 0 2556 0 0 926 0 --:--:-- 0:00:02 --:--:-- 926
100 2806 0 2806 0 0 984 0 --:--:-- 0:00:02 --:--:-- 984
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/main.css"/><title>Hello</title></head><body><noscript><b>Enable JavaScript to run this app.</b></noscript><!--$--><main><nav><a href="/">Home</a></nav><aside class="sidebar"><!--$?--><template id="B:0"></template><div class="spinner spinner--active" role="progressbar" aria-busy="true"></div><!--/$--></aside><article class="post"><!--$?--><template id="B:1"></template><div class="spinner spinner--active" role="progressbar" aria-busy="true"></div><!--/$--><section class="comments"><h2>Comments</h2><!--$?--><template id="B:2"></template><div class="spinner spinner--active" role="progressbar" aria-busy="true"></div><!--/$--></section><h2>Thanks for reading!</h2></article></main><!--/$--><script>assetManifest = {"main.js":"/main.js","main.css":"/main.css"};</script></body></html><div hidden id="S:0"><template id="P:3"></template></div><div hidden id="S:1"><template id="P:4"></template></div><div hidden id="S:2"><template id="P:5"></template></div><div hidden id="S:3"><h1>Archive</h1><ul><li>May 2021</li><li>April 2021</li><li>March 2021</li><li>February 2021</li><li>January 2021</li><li>December 2020</li><li>November 2020</li><li>October 2020</li><li>September 2020</li></ul></div><script>function $RS(a,b){a=document.getElementById(a);b=document.getElementById(b);for(a.parentNode.removeChild(a);a.firstChild;)b.parentNode.insertBefore(a.firstChild,b);b.parentNode.removeChild(b)};$RS("S:3","P:3")</script><script>function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}};$RC("B:0","S:0")</script><div hidden id="S:4"><h1>Hello world</h1><p>This demo is <!-- --><b>artificially slowed down</b>. Open<!-- --> <!-- --><code>server/delays.js</code> to adjust how much different things are slowed down.<!-- --></p><p>Notice how HTML for comments "streams in" before the JS (or React) has loaded on the page.</p><p>Also notice that the JS for comments and sidebar has been code-split, but HTML for it is still included in the server output.</p></div><script>$RS("S:4","P:4")</script><script>$RC("B:1","S:1")</script><div hidden id="S:5"><p class="comment">Wait, it doesn't wait for React to load?</p><p class="comment">How does this even work?</p><p class="comment">I like marshmallows</p></div><script>$RS("S:5","P:5")</script><script>$RC("B:2","S:2")</script>
The streaming SSR examples make use of WasmEdge’s unique asynchronous networking capabilities and ES6 module support (i.e., the rollup bundled JS file contains ES6 modules). You can learn more about async networking and ES6 in this book.