-
Notifications
You must be signed in to change notification settings - Fork 7
One server
Summary: In StartER, the architecture relies on a single server by attaching a Vite development server to the Express server. This simplifies development while enabling seamless Server-Side Rendering (SSR).
Tip
If some terms seem complex (SSR, HMR, Middleware...), feel free to consult the Technical glossary.
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 we 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.
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
This organization clearly separates responsibilities between the client, the server, and the shared routes.
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-rendered HTML:
app.use(/(.*)/, async (req, res, next) => {
const url = req.originalUrl;
try {
// Prevent caching of the HTML page
// SSR rendering depends on the user (authentication)
res.set("Cache-Control", "private, no-store");
const indexHtml = fs.readFileSync("index.html", "utf-8");
// 1. Apply Vite HTML transforms
const template = await vite.transformIndexHtml(url, indexHtml);
// 2. Load the server entry
const { render } = await vite.ssrLoadModule("/src/entry-server");
// 3. Render the app HTML
await render(template, req, res);
} catch (err) {
next(err);
}
});The role of the render function (imported from 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. Now let's see how the render function combines these elements to produce the HTML sent to the browser.
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, you need to:
- Produce a client build as usual;
- Produce an SSR build, which can be loaded directly via
import()to avoid using Vite'sssrLoadModulemodule.
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 deployment logic is in server.ts, where StartER adapts its behavior according to process.env.NODE_ENV.
Note
See the full code for details: server.ts, entry-server.tsx, entry-client.tsx.
Warning
Sealed advanced code: consider the SSR architecture exposed in entry-server.tsx and server.ts as sealed. These advanced concepts should only be modified if you fully understand the React server rendering cycle and the mechanics of renderToPipeableStream. As a developer, strictly restrict your work to developing features in the express/modules and react/components folders.
- Native usage: take advantage of this unified server to easily share state or authentication between the API and the client via secure HTTP-only cookies.
AI co-creation
Getting started
Explanations
How-To Guides
Reference
Digging deeper