How to Update Server-Side Rendering in React

Updating Server-Side Rendering (SSR) in React is essential for leveraging modern performance features like HTML streaming and selective hydration. This article explains how to transition legacy React SSR setups—which relied on synchronous rendering—to the modern React 18+ architecture using streaming APIs, update your client-side hydration, and utilize modern frameworks to simplify the process.

Step 1: Replace Legacy Server-Side APIs

Older React SSR configurations relied on renderToString or renderToNodeStream from the react-dom/server package. These methods are synchronous and force the server to wait for the entire HTML page to be generated before sending anything to the client.

To update your server, replace these legacy APIs with renderToPipeableStream (for Node.js environments) or renderToReadableStream (for Web Streams/Edge environments).

Here is how to implement renderToPipeableStream on an Express server:

import { renderToPipeableStream } from 'react-dom/server';

app.get('*', (req, res) => {
  const { pipe } = renderToPipeableStream(<App />, {
    bootstrapScripts: ['/main.js'],
    onShellReady() {
      res.setHeader('content-type', 'text/html');
      pipe(res);
    },
    onShellError(error) {
      res.statusCode = 500;
      res.send('<!doctype html><p>Loading...</p>');
    },
    onError(err) {
      console.error(err);
    }
  });
});

This updates your SSR setup to stream HTML to the browser incrementally, improving the Time to First Byte (TTFB).

Step 2: Update Client-Side Hydration

Once the server streams the HTML, the client must attach event listeners to make the page interactive. In React 18+, the legacy hydrate method has been deprecated.

To update your client-side entry point, import hydrateRoot from react-dom/client:

// Before (Legacy)
// import { hydrate } from 'react-dom';
// hydrate(<App />, document.getElementById('root'));

// After (Modern)
import { hydrateRoot } from 'react-dom/client';

hydrateRoot(document.getElementById('root'), <App />);

Using hydrateRoot enables Concurrent Features on the client, allowing React to prioritize user interactions during the hydration process.

Step 3: Implement Suspense for Selective Hydration

To take full advantage of updated SSR, wrap slow-loading components in React’s <Suspense> component. This allows the server to send a placeholder (like a spinner) for slow components, send the rest of the HTML immediately, and then stream the slow component’s HTML once its data is ready.

import { Suspense, lazy } from 'react';

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <Header />
      <Suspense fallback={<p>Loading content...</p>}>
        <HeavyComponent />
      </Suspense>
      <Footer />
    </div>
  );
}

This prevents slow data fetching or heavy components from blocking the entire page render on the server.

Step 4: Leverage Meta-Frameworks for Seamless Updates

Manually managing custom SSR configurations, bundling, and hydration can become complex. The most reliable way to update SSR in React is by migrating to a modern meta-framework.