DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

Server-Side Rendering with React: A Practical JavaScript Guide

Server-Side Rendering (SSR) is an important technique for building fast, accessible, and search-engine-friendly React applications. Instead of sending an almost empty HTML document and waiting for the browser to execute JavaScript, SSR allows a server to generate HTML for a React component before sending the response to the client.

This approach can improve the initial rendering experience, especially for content-heavy websites, product pages, blogs, and dashboards that need meaningful content to appear quickly. However, SSR also introduces additional considerations around server performance, data fetching, caching, and hydration.

In this guide, we will explore how server-side rendering works, how React can generate HTML on a Node.js server, and how the browser can make that HTML interactive. We will also build a runnable JavaScript example using Express and React's server-rendering APIs.

Understanding Server-Side Rendering in React

What Is Server-Side Rendering?

Server-side rendering is the process of rendering a React component into HTML on the server. The server sends that HTML to the browser, allowing the user to see the initial page before client-side JavaScript finishes loading. React can then hydrate the existing HTML to attach event handlers and make the interface interactive.

Traditional client-side rendering typically sends a minimal HTML document and relies on JavaScript to construct the interface in the browser. With SSR, the initial HTML can already contain meaningful page content, which may improve perceived performance and help search engines that consume HTML directly. Actual SEO and performance outcomes still depend on factors such as page structure, metadata, caching, and JavaScript execution.

How the SSR Lifecycle Works

A typical SSR application begins when the browser requests a URL. The Node.js server handles the request, retrieves any required data, renders the React component into HTML, and returns the resulting document. The browser displays that HTML and loads the client-side JavaScript bundle.

React hydration connects the client-side React application to the server-rendered markup. The initial client render must match the server output to avoid hydration mismatches. Browser-only APIs, inconsistent date formatting, random values, and different server and client data can cause problems when they affect the initial markup.

Building a Minimal React SSR Application

The example below uses Express to handle HTTP requests and React's renderToString API to generate HTML. It also demonstrates a separate client entry point that uses hydrateRoot to make the rendered page interactive. The code assumes a modern Node.js environment and an installed React, React DOM, Express, and esbuild toolchain.

The server generates the initial page for each request, including a title and a list of products. The browser receives the same initial product data and hydrates the page using the client entry point. This example intentionally keeps the data static so the rendering lifecycle is easy to understand; a production application would usually retrieve data from a database or API and apply appropriate caching and error handling.

SSR Best Practices

Keep server-only secrets, database credentials, and privileged API calls out of client bundles. Never serialize sensitive information into the HTML because anything embedded in a browser response can be inspected by the user. If you serialize application data into a script element, escape it correctly to prevent script injection vulnerabilities.

For larger applications, consider streaming server rendering with React's renderToPipeableStream API, which can send HTML progressively instead of waiting for the complete render. Frameworks such as Next.js also provide routing, data-fetching conventions, caching strategies, and production optimizations that would otherwise need to be implemented manually.

SSR is not automatically faster in every scenario. Server rendering consumes server resources and can increase response latency when data fetching is slow. Measure real user performance, cache suitable responses, keep server work efficient, and choose SSR, static rendering, or client-side rendering according to the needs of each page.

import express from "express";
import React from "react";
import { renderToString } from "react-dom/server";
import { createServer } from "node:http";

const app = express();
const PORT = Number(process.env.PORT) || 3000;

// Sample data keeps this demonstration independent of external services.
const products = [
  { id: 1, name: "Mechanical Keyboard", price: 2499 },
  { id: 2, name: "Wireless Mouse", price: 999 },
  { id: 3, name: "USB-C Hub", price: 1499 },
];

// This component is rendered on the server and hydrated in the browser.
function ProductPage({ items }) {
  return React.createElement(
    "main",
    { className: "container" },
    React.createElement("h1", null, "Developer Essentials"),
    React.createElement(
      "p",
      null,
      "Browse our curated collection of developer accessories."
    ),
    React.createElement(
      "ul",
      null,
      ...items.map((product) =>
        React.createElement(
          "li",
          { key: product.id },
          `${product.name} - ₹${product.price}`
        )
      )
    ),
    React.createElement(
      "button",
      {
        id: "refresh-button",
        type: "button",
      },
      "Show product count"
    ),
    React.createElement("p", {
      id: "message",
      "aria-live": "polite",
    })
  );
}

// Serve the browser bundle generated from client.js.
app.use("/assets", express.static("public"));

// Handle the home page and render its initial HTML on the server.
app.get("/", (req, res) => {
  console.log("Step 1: Received request for the home page.");

  try {
    // In production, load request-specific data from a trusted data source.
    console.log("Step 2: Preparing product data.");
    const initialData = JSON.stringify(products).replace(
      /[<>&\u2028\u2029]/g,
      (character) => ({
        "<": "\\u003c",
        ">": "\\u003e",
        "&": "\\u0026",
        "\u2028": "\\u2028",
        "\u2029": "\\u2029",
      })[character]
    );

    console.log("Step 3: Rendering the React component to HTML.");
    const appHtml = renderToString(
      React.createElement(ProductPage, { items: products })
    );

    // The JSON payload is escaped to prevent HTML parsing and script injection.
    const documentHtml = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="description" content="Explore useful accessories for developers.">
  <title>Developer Essentials</title>
</head>
<body>
  <div id="root">${appHtml}</div>
  <script id="initial-data" type="application/json">${initialData}</script>
  <script src="/assets/client.js" defer></script>
</body>
</html>`;

    console.log("Step 4: Sending server-rendered HTML to the browser.");
    res.status(200).type("html").send(documentHtml);
  } catch (error) {
    // Log diagnostic details on the server without exposing them to clients.
    console.error("SSR rendering failed:", error);
    res.status(500).type("text").send("Unable to render the page.");
  }
});

// Provide a basic health endpoint for local testing and monitoring.
app.get("/health", (req, res) => {
  res.status(200).json({ status: "ok" });
});

// Create an HTTP server so the startup lifecycle is explicit.
const server = createServer(app);

server.listen(PORT, () => {
  console.log(`Step 5: SSR server listening at http://localhost:${PORT}`);
  console.log("Open the home page and inspect View Page Source.");
});

// Handle termination signals so the server can shut down gracefully.
function shutdown() {
  console.log("Shutting down the SSR server...");
  server.close((error) => {
    if (error) {
      console.error("Server shutdown failed:", error);
      process.exitCode = 1;
      return;
    }
    console.log("Server closed successfully.");
  });
}

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

// CLIENT ENTRY POINT: place the following in client.js and bundle it
// to public/client.js with esbuild before starting the server.
//
// import React from "react";
// import { hydrateRoot } from "react-dom/client";
//
// function ProductPage({ items }) {
//   return React.createElement(
//     "main",
//     { className: "container" },
//     React.createElement("h1", null, "Developer Essentials"),
//     React.createElement("p", null,
//       "Browse our curated collection of developer accessories."),
//     React.createElement("ul", null, ...items.map((product) =>
//       React.createElement("li", { key: product.id },
//         `${product.name} - ₹${product.price}`))),
//     React.createElement("button", { id: "refresh-button", type: "button" },
//       "Show product count"),
//     React.createElement("p", { id: "message", "aria-live": "polite" })
//   );
// }
//
// const dataElement = document.getElementById("initial-data");
// const rootElement = document.getElementById("root");
//
// if (dataElement && rootElement) {
//   const items = JSON.parse(dataElement.textContent);
//   console.log("Step 6: Hydrating server-rendered HTML.");
//
//   hydrateRoot(rootElement, React.createElement(ProductPage, { items }));
//
//   // Attach the browser interaction after React hydration.
//   document.getElementById("refresh-button")?.addEventListener("click", () => {
//     document.getElementById("message").textContent =
//       `There are ${items.length} products available.`;
//   });
//
//   console.log("Step 7: The page is ready for interaction.");
// }

Enter fullscreen mode Exit fullscreen mode

Conclusion

Server-side rendering allows React applications to deliver meaningful HTML before the browser completes client-side JavaScript execution. By combining Node.js, React's server-rendering APIs, and client-side hydration, developers can create pages that balance initial content delivery with interactive experiences.

The best implementation depends on the application rather than on SSR alone. Static generation can be more efficient for content that rarely changes, while client-side rendering can work well for highly interactive experiences that do not need server-generated initial content. SSR is especially useful when dynamic, request-specific content must be available in the initial response.

Start with a small SSR implementation, understand the relationship between server-rendered HTML and hydration, and measure the results before adding complexity. As your application grows, evaluate streaming, caching, security, and framework-level features to keep rendering reliable and maintainable.

Top comments (0)