Skip to content

Un serveur unique fr FR

rocambille edited this page Apr 28, 2026 · 1 revision

Résumé : Dans StartER, l'architecture s'appuie sur un serveur unique en attachant un serveur de développement Vite au serveur Express. Cela simplifie le développement tout en permettant le rendu côté serveur (SSR) de manière fluide.

Tip

Si certains termes vous semblent complexes (SSR, HMR, Middleware...), n'hésitez pas à consulter le Glossaire technique.

Les concepts de base

Vite fournit un serveur de développement qui intègre des fonctionnalités avancées (par exemple, le HMR ou la gestion native de JSX). L'outil fonctionne parfaitement pour notre application React. Mais lorsque nous souhaitons inclure une application Express, les choses peuvent devenir compliquées pour gérer 2 serveurs indépendants (le serveur de développement Vite et le serveur Express) et les faire communiquer ensemble.

La documentation de Vite fournit un guide sur le SSR. Cette page reprend les étapes implémentées dans StartER et les complète avec des éléments spécifiques à React Router.

Implémentation dans StartER

Structure

Si nous isolons les éléments clés du SSR dans StartER, la structure des fichiers source est la suivante :

.
├── index.html
├── server.ts             # Serveur d'application principal
└── src
    ├── entry-client.tsx  # Monte l'application sur un élément DOM
    ├── entry-server.tsx  # Rend l'application en utilisant l'API SSR de Vite
    └── react
        └── routes.tsx    # Point d'entrée du code React, indépendant de l'environnement client/server

Cette organisation sépare clairement les responsabilités entre le client, le serveur et les routes partagées.

Le fichier index.html référence entry-client et inclut un marqueur où le balisage rendu par le serveur est injecté :

<div class="container" id="root"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client"></script>

Vous pouvez utiliser n'importe quel marqueur que vous préférez à la place de <!--ssr-outlet-->, à condition qu'il puisse être détecté sans ambiguité.

Configuration du serveur de développement

Dans notre application SSR, Vite est utilisé en mode middleware. Cela nous permet d'avoir un contrôle total sur notre serveur principal et de dissocier Vite de l'environnement de production.

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
  });
}

Ici, vite est une instance de ViteDevServer. vite.middlewares est une instance de Connect qui peut être utilisée comme middleware dans notre application Express.

L'étape suivante consiste à implémenter le gestionnaire * (/(.*)/ à partir d'Express 5) pour servir le code HTML rendu par le serveur :

app.use(/(.*)/, async (req, res, next) => {
  const url = req.originalUrl;

  try {
    // Empêcher la mise en cache de la page HTML
    // Le rendu SSR dépend de l'utilisateur (authentification)
    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);
  }
});

Le rôle de la fonction render (importée depuis entry-server.tsx) est de rendre l'HTML de l'application et de l'injecter dans le template à la place du marqueur <!--ssr-outlet-->.

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);
};

C'est le moment où la documentation de React Router prend le relais.

Pour obtenir un rendu serveur compatible avec React Router, nous transformons nos routes en gestionnaire de requêtes avec createStaticHandler.

import { createStaticHandler } from "react-router";

import routes from "./react/routes";

const { query, dataRoutes } = createStaticHandler(routes);

L'étape suivante est d'obtenir le contexte de routage et d'effectuer le rendu. Voyons maintenant comment la fonction render combine ces éléments pour produire le HTML envoyé au navigateur.

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);
}

Un point à noter sur l'utilisation de renderToString : la méthode ne prend pas en charge les <Suspense> de React. Dans StartER, nous utilisons renderToPipeableStream, qui prend en charge <Suspense> sur le serveur.

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);
  });
};

Déployer en production

Pour déployer un projet SSR en production, vous devez :

  • Produire un build client comme d'habitude ;
  • Produire un build SSR, qui peut être chargée directement via import() afin d'éviter de passer par le module ssrLoadModule de Vite.

Les scripts de build dans package.json ressemble à ceci :

{
  "scripts": {
    "build:client": "vite build --outDir dist/client",
    "build:server": "vite build --outDir dist/server --ssr src/entry-server"
  }
}

Notez l'option --ssr qui indique qu'il s'agit d'un build SSR, suivie de l'entrée SSR src/entry-server.

Le reste de la logique de déploiement se trouve dans server.ts, où StartER adapte son comportement selon process.env.NODE_ENV.

Note

Voir les codes complets pour les détails : server.ts, entry-server.tsx, entry-client.tsx.

Bonnes pratiques et cas d'usage

Warning

Code avancé scellé : considérez l'architecture SSR exposée dans entry-server.tsx et server.ts comme scellée. Ces concepts avancés ne doivent être modifiés que si vous maîtrisez le cycle de rendu serveur de React et la mécanique de renderToPipeableStream. En tant que développeur, développez vos fonctionnalités dans les dossiers express/modules et react/components.

  • Utilisation native : profitez de ce serveur unifié pour partager facilement l'état ou l'authentification entre l'API et le client via des cookies sécurisés HTTP-only.

Voir aussi

Clone this wiki locally