How to Build a Server-Side React App Using Vite and Express

In this post I’ll explain how you can enable server-side rendering and server-side data fetching in React… without using a framework!
Whilst the code in this post isn’t what I’d call “production ready”, it should help explain two of React’s built-in methods,hydrateRoot
and renderToString
, both of which are required to enable server-side rendering in React.
If you’re keen to jump ahead, all the code used in this post can be seen in the following GitHub repository.
Two small caveats:
- I won’t be covering how to deploy a React SSR Application.
- Most of what I’ll be explaining can be found in the Vite docs: Server-side Rendering.
Setup and Install Dependencies
The first thing you’ll need is to initialize a new npm package. (the -y flag skips the questionnaire and uses the npm defaults when creating a package.json)
1 |
npm init -y |
Now you can install the dependencies.
1 |
npm install react react-dom express |
And lastly, install the development dependencies.
1 |
npm install vite @vitejs/plugin-react -D |
Add Scripts to package.json
There are five scripts that you’ll need to add. One is for development, the remaining four are for creating a production build, plus a serve script so you can preview the production build in the browser.
1 2 3 4 5 6 7 8 9 |
// package.json "scripts": { "dev": "node server-dev.js", "build:client": "vite build --outDir dist/client", "build:server": "vite build --ssr src/entry-server.jsx --outDir dist/server", "build": "npm run build:client && npm run build:server", "serve": "node server-prod.js", ... }, |
- dev. This script starts the Vite development server.
- build:client. This script bundles the index.html and entry-client.jsx.
- build:server. This script bundles entry-server.jsx.
- build. This script runs both of the above “build:” scripts.
- serve: This script runs server-prod.js (I’ll explain what this is shortly)>
Add type:module to package.json
Vite’s dev server uses native ES Modules, so you’ll need to add “type” : “module” to your package.json. If you don’t do this you’ll likely see errors relating to: Cannot use import statement outside a module.
1 2 3 4 5 6 7 8 |
// package.json { "name": "...", "type": "module", "scripts": { ... }, } |
Creating the src Files
index.html
At the root of your project created a file called index.html. This acts as the “template” for the application. There are two things to note in this file.
- The div id of ”app” is the target DOM node used by React when hydrateRoot is called.
- The comment of <!–outlet–> is replaced by the server with the result of React’s renderToString function.
1 2 3 4 5 6 7 8 9 10 11 12 |
//index.html <html lang='en'> <head> <meta charset='UTF-8' /> <meta name='viewport' content='width=device-width, initial-scale=1.0' /> <title>Simple React SSR Vite Express</title> </head> <body> <div id='app'><!--outlet--></div> <script type='module' src='/src/entry-client.jsx'></script> </body> </html> |
app.jsx
Create a src directory at the root of your project, then create a file called app.jsx.
This is a simple function component that returns some basic HTML to be rendered by the browser. The component uses export default syntax.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// src/app.jsx import { useState } from 'react'; const App = () => { const [count, setCount] = useState(0); return ( <main> <h1>App</h1> <p>Lorem Ipsum</p> <div> <div>{count}</div> <button onClick={() => setCount(count + 1)}>Count</button> </div> </main> ); }; export default App |
entry-client.jsx
Create a file in the src directory called entry-client.jsx. This file is responsible for displaying the <App /> component in the div with an id of app.
You can read more about hydrateRoot in the React docs here: hydrateRoot.
1 2 3 4 5 6 |
//src/entry-client.jsx import { hydrateRoot } from 'react-dom/client'; import App from './app'; hydrateRoot(document.getElementById('app'), <App />); |
entry-server.jsx
Create a file in the src directory called entry-server.jsx. This file is responsible for “converting” the <App /> component into a plain HTML string suitable for use in the browser.
You can read more about renderToString in the React docs here: renderToString. This file exports a named function called render.
1 2 3 4 5 6 7 8 |
//src/entry-server.jsx import { renderToString } from 'react-dom/server'; import App from './app'; export const render = () => { return renderToString(<App />); }; |
Vite Config
At the root of your project create a file named vite.config.js and add the following code snippet.
1 2 3 4 5 6 7 |
//vite.config.js import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], }); |
Creating The Development Server
Create a file in the root of your project called server-dev.js. This is the server that is started when you run npm run dev.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
//server-dev.js import fs from 'fs'; import express from 'express'; import { createServer } from 'vite'; const app = express(); const vite = await createServer({ server: { middlewareMode: true, }, appType: 'custom', }); app.use(vite.middlewares); app.use('*', async (req, res) => { const url = req.originalUrl; try { const template = await vite.transformIndexHtml(url, fs.readFileSync('index.html', 'utf-8')); const { render } = await vite.ssrLoadModule('/src/entry-server.jsx'); const html = template.replace(`<!--outlet-->`, render); res.status(200).set({ 'Content-Type': 'text/html' }).end(html); } catch (error) { res.status(500).end(error); } }); app.listen(4173, () => { console.log('http://localhost:4173.'); }); |
app = express()
As you’d expect, this is an instance of Express, it’s common to define it as a const called app.
createServer
This creates a Vite development server, the additional config is required is so Vite knows to hand control over to express.
app.use(vite.middleware)
This ensures any requests to express get passed back over to the Vite development server.
app.use(‘*’)
The express app deals with all incoming requests, the url from each request can be extracted from the req object and is required by Vite when transforming index.html.
template
The template is the starting point for the page. It’s populated with the HTML from app.jsx on the server by the render function, and then re-populated again, or hydrated, in the browser using the same HTML from app.jsx.
{ render }
As mentioned above, this function is responsible for “converting” React code into a plain HTML string.
html
This is where everything comes together. Using .replace you can target the from index.html and replace it with the return value from the render function.
.end(html)
Using the standard .end() Express method you can return a status 200, set the content type, then pass in the HTML to display in the browser.
These are the fundamental principles behind rendering React on the server; but since Vite is a development tool, you’ll have to make some changes in order to create an Express server that would work if deployed.
Creating The Production Server
Create a file in the root of your project called server-prod.js. This is the server that is started when you run npm run serve. The production server is very similar to the development server, with some notable differences.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
//server-prod.js import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import express from 'express'; const app = express(); app.use(express.static(path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'dist/client'), { index: false })); app.use('*', async (_, res) => { try { const template = fs.readFileSync('./dist/client/index.html', 'utf-8'); const { render } = await import('./dist/server/entry-server.js'); const html = template.replace(`<!--outlet-->`, render); res.status(200).set({ 'Content-Type': 'text/html' }).end(html); } catch (error) { res.status(500).end(error); } }); app.listen(5173, () => { console.log('http://localhost:5173.'); }); |
No Vite
Vite is only used while in development mode. When you run npm run build, Vite uses Rollup under the hood to compile all the required files and output them to a npm run build directory. As such, there’s no need to use Vite’s createServer in production.
express.static
The express.static() function is built-in middleware and can be used to serve the static files (HTML, .js) that are required for React to run in the browser.
template and { render }
These are broadly the same as in the development server, but the Vite specific methods (transformIndexHtml and ssrLoadModule) have been removed. The paths now point to the ./dist directory instead of the src files.
That wraps up the first part of this post, but there’s one piece missing…
Server-Side Data Fetching
A React app that has server-side rendering capabilities can also take advantage of server-side date fetching. There are two advantages to this.
- Server-side requests can be used to make secure connections with databases (for example).
- Data from the server-side request will still be displayed in the browser even when JavaScript is disabled, or before hydration has occurred.
There are quite a few changes required in order for this to work and I’ll explain what each of them are; but, if you’d prefer to see them on GitHub, I’ve prepared a pull request with all the changes on the following link.
package.json
Add a new script called “build:function” and point it to a new function.js file (which you’ll create next). Then modify the build script to include && npm run build:function.
1 2 3 4 5 6 7 8 |
//package.json "scripts": { ... + "build:function": "vite build --ssr src/function.js --outDir dist/function", - "build": "npm run build:client && npm run build:server", + "build": "npm run build:client && npm run build:server && npm run build:function", ... } |
function.js
Create a file in the src directory called function.js. This file contains an async function called getServerData that will be called from the server.
1 2 3 4 5 6 7 |
//src/function.js export const getServerData = async () => { const response = await fetch('https://dummyjson.com/products/1'); const data = await response.json(); return data; }; |
server-dev.js
The changes here relate to importing the new function.js file, calling the getServerData async function then passing the data back to the render function. It’s also necessary to create a script element that will populate window.__data__ with the newly fetched server data. Adding the data to the window will allow React to access the same data as the server when it hydrates the page.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
//server-dev.js app.use('*', async (req, res) => { const url = req.originalUrl; try { const template = await vite.transformIndexHtml(url, fs.readFileSync('index.html', 'utf-8')); const { render } = await vite.ssrLoadModule('/src/entry-server.jsx'); + const { getServerData } = await vite.ssrLoadModule('/src/function.js'); + const data = await getServerData(); + const script = `<script>window.__data__=${JSON.stringify(data)}</script>`; - const html = template.replace(`<!--ssr-outlet-->`, render); + const html = template.replace(`<!--outlet-->`, `${render(data)} ${script}`); res.status(200).set({ 'Content-Type': 'text/html' }).end(html); } catch (error) { res.status(500).end(error); } }); |
server-prod.js
The changes here are almost identical to the changes made to the development server, with the exception of the path where function.js can be found.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
app.use('*', async (_, res) => { try { const template = fs.readFileSync('./dist/client/index.html', 'utf-8'); const { render } = await import('./dist/server/entry-server.js'); + const { getServerData } = await import('./dist/function/function.js'); + const data = await getServerData(); + const script = `<script>window.__data__=${JSON.stringify(data)}</script>`; - const html = template.replace(`<!--outlet-->`, render); + const html = template.replace(`<!--outlet-->`, `${render(data)} ${script}`); res.status(200).set({ 'Content-Type': 'text/html' }).end(html); } catch (error) { res.status(500).end(error); } }); |
entry-client.jsx
The changes here are to define a new variable called data, then set it to equal the value of window.__data__. With the data variable now containing the data that was requested server-side, it can be passed on to the <App /> component via a prop called data.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
//entry-client.jsx import { hydrateRoot } from 'react-dom/client'; import App from './app'; + let data; + if (typeof window !== 'undefined') { + data = window.__data__; + } - hydrateRoot(document.getElementById('app'), <App />); + hydrateRoot(document.getElementById('app'), <App data={data} />); |
entry-server.jsx
It’s a similar story with entry-server.jsx; but this time, instead of grabbing the data from the window object, you can access the data that was passed through from the server when the render function was called with a parameter of data. The same approach can then be used to pass the data on to the <App /> component via a prop called data.
1 2 3 4 5 6 7 8 9 10 |
//entry-server.jsx import { renderToString } from 'react-dom/server'; import App from './app'; - export const render = () => { + export const render = (data) => { - return renderToString(<App />); + return renderToString(<App data={data} />); }; |
app.jsx
The last change to make is to de-structure the new data prop and return it in an HTML <pre> element so it’s visible on the page.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//src/app.jsx import { useState } from 'react'; - const App = () => { + const App = ({ data }) => { const [count, setCount] = useState(0); return ( <main> ... + <pre>{JSON.stringify(data, null, 2)}</pre> </main> ); }; export default App; |
Finished
And there you have it, server-side rendering and server-side data fetching without using a framework!
I’ve been using React since ~2017 and I never really understood hydrateRoot or renderToString, but working through this example project I now have a much better understanding of how React actually works — and much more appreciation for what React-powered frameworks actually do.
I’m also really impressed with Vite — everything from the docs to the developer experience is top-notch. Plus, in case you missed it, Remix just announced their new Vite plugin; and if the Remix team are using Vite, that’s a good indication that Vite is every bit as good as it looks!