One server en US - rocambille/start-express-react GitHub Wiki
Vite provides a development server that integrates advanced features (e.g., HMR or native JSX support). The tool works perfectly for our React application.
But when you want to include an Express application, things can get complicated when managing two independent servers (the Vite development server and the Express server) and making them communicate with each other.
In StartER, we chose to implement a single server by attaching a Vite development server to our Express application server.
As a bonus, this "one server" allows us to offer Server-Side Rendering (SSR). SSR refers to running an application in Node.js, pre-rendering it to HTML, and finally hydrating it on the client.
The Vite documentation provides a guide to SSR. This page summarizes the steps implemented in StartER and supplements them with elements specific to React Router.
If we isolate the key elements of SSR in StartER, the structure of the source files is as follows:
.
├── index.html
├── server.ts # Main application server
└── src
├── entry-client.tsx # Mounts the application on a DOM element
├── entry-server.tsx # Renders the application using Vite's SSR API
└── react
└── routes.tsx # Entry point for React code, independent of the client/server environment
The index.html
file references entry-client
and includes a marker where the server-rendered markup is injected:
<div class="container" id="root"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client"></script>
You can use any marker you prefer instead of <!--ssr-outlet-->
, as long as it can be detected unambiguously.
In our SSR application, Vite is used in middleware mode. This allows us to have complete control over our main server and decouple Vite from the production environment.
import express from "express";
import { createServer as createViteServer } from "vite";
const app = express();
const isProduction = process.env.NODE_ENV === "production";
if (isProduction === false) {
// Create Vite server in middleware mode and configure the app type as
// 'custom', disabling Vite's own HTML serving logic so parent server
// can take control
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "custom",
});
// Use vite's connect instance as middleware. If you use your own
// express router (express.Router()), you should use router.use
// When the server restarts (for example after the user modifies
// vite.config.js), `vite.middlewares` is still going to be the same
// reference (with a new internal stack of Vite and plugin-injected
// middlewares). The following is valid even after restarts.
app.use(vite.middlewares);
app.use(/(.*)/, async (req, res, next) => {
// serve index.html - we will tackle this next
});
}
Here, vite
is an instance of ViteDevServer
. vite.middlewares
is an instance of Connect
that can be used as middleware in our Express application.
The next step is to implement the *
handler (/(.*)/
as of Express 5) to serve the server-generated HTML:
app.use(/(.*)/, async (req, res, next) => {
const url = req.originalUrl;
const indexHtml = fs.readFileSync("index.html", "utf-8");
// 1. Apply Vite HTML transforms. This injects the Vite HMR client,
// and also applies HTML transforms from Vite plugins, e.g. global
// preambles from @vitejs/plugin-react
const template = await vite.transformIndexHtml(url, indexHtml);
// 2. Load the server entry. ssrLoadModule automatically transforms
// ESM source code to be usable in Node.js! There is no bundling
// required, and provides efficient invalidation similar to HMR.
const { render } = await vite.ssrLoadModule("/src/entry-server");
// 3. render the app HTML. This assumes entry-server.js's exported
// `render` function calls appropriate framework SSR APIs,
// e.g. ReactDOMServer.renderToString()
await render(template, req, res);
});
The role of the render
function in entry-server.tsx
is to render the application's HTML and inject it into the template in place of the <!--ssr-outlet-->
tag.
import type { Request, Response } from "express";
import routes from "./react/routes";
export const render = async (template: string, req: Request, res: Response) => {
const appHtml = // render using routes imported from ./react/routes
const html = template.replace("<!--ssr-outlet-->", () => appHtml);
res.status(200).set("Content-Type", "text/html; charset=utf-8").end(html);
};
This is where the React Router documentation comes in.
To achieve server-side rendering compatible with React Router, we transform our routes into request handlers with createStaticHandler
.
import { createStaticHandler } from "react-router";
import routes from "./react/routes";
const { query, dataRoutes } = createStaticHandler(routes);
The next step is to get the routing context and render.
import type { Request, Response } from "express";
import { renderToString } from "react-dom/server";
import {
createStaticHandler,
createStaticRouter,
StaticRouterProvider,
} from "react-router";
import routes from "./react/routes";
const { query, dataRoutes } = createStaticHandler(routes);
export const render = async (template: string, req: Request, res: Response) => {
// 1. Run actions/loaders to get the routing context with `query`
const context = await query(
new Request(`${req.protocol}://${req.get("host")}${req.originalUrl}`),
);
// If `query` returns a Response, send it raw
if (context instanceof Response) {
for (const [key, value] of context.headers.entries()) {
res.set(key, value);
}
return res.status(context.status).end(context.body);
}
// Setup headers from action and loaders from deepest match
const leaf = context.matches[context.matches.length - 1];
const actionHeaders = context.actionHeaders[leaf.route.id];
if (actionHeaders) {
for (const [key, value] of actionHeaders.entries()) {
res.set(key, value);
}
}
const loaderHeaders = context.loaderHeaders[leaf.route.id];
if (loaderHeaders) {
for (const [key, value] of loaderHeaders.entries()) {
res.set(key, value);
}
}
// 2. Create a static router for SSR
const router = createStaticRouter(dataRoutes, context);
// 3. Render everything with StaticRouterProvider
const appHtml = renderToString(
<StrictMode>
<StaticRouterProvider router={router} context={context} />
</StrictMode>,
);
const html = template.replace("<!--ssr-outlet-->", () => appHtml);
// 4. Send a response
res.status(200).set("Content-Type", "text/html; charset=utf-8").end(html);
}
One thing to note about using renderToString
is that the method doesn't support React's <Suspense>
. In StartER, we use renderToPipeableStream
, which supports <Suspense>
on the server.
import { Transform } from "node:stream";
import type { Request, Response } from "express";
import { renderToPipeableStream } from "react-dom/server";
import {
StaticRouterProvider,
createStaticHandler,
createStaticRouter,
} from "react-router";
import routes from "./react/routes";
const { query, dataRoutes } = createStaticHandler(routes);
export const render = async (template: string, req: Request, res: Response) => {
// 1. Run actions/loaders to get the routing context with `query`
// ...
// 2. Create a static router for SSR
const router = createStaticRouter(dataRoutes, context);
// 3. Render everything with StaticRouterProvider
const { pipe } = renderToPipeableStream(
<StaticRouterProvider router={router} context={context} />
);
// 4. Send a response
res.status(200).set("Content-Type", "text/html; charset=utf-8");
const [htmlStart, htmlEnd] = template.split("<!--ssr-outlet-->");
res.write(htmlStart);
const transformStream = new Transform({
transform(chunk, encoding, callback) {
res.write(chunk, encoding);
callback();
},
});
pipe(transformStream);
transformStream.on("finish", () => {
res.end(htmlEnd);
});
};
To deploy an SSR project in production, we need to:
- Produce a client build as usual;
- Produce an SSR build, which can be loaded directly via
import()
to avoid using Vite'sssrLoadModule
module.
The build scripts in package.json
look like this:
{
"scripts": {
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --outDir dist/server --ssr src/entry-server"
}
}
Note the --ssr
option, which indicates that this is an SSR build, followed by the SSR entry src/entry-server
.
The rest of the magic happens in server.ts
, where we implement production-specific logic by checking process.env.NODE_ENV
.
See the full code for details: