Home > Web Front-end > JS Tutorial > Advanced React SSR Techniques with Streaming and Dynamic Data

Advanced React SSR Techniques with Streaming and Dynamic Data

Susan Sarandon
Release: 2025-01-05 03:49:38
Original
230 people have browsed it

Advanced React SSR Techniques with Streaming and Dynamic Data

As your application grows, so do the challenges. To stay ahead, mastering advanced SSR techniques is essential for delivering a seamless and high-performance user experience.

Having built a foundation for server-side rendering in React projects in the previous article, I am excited to share features that can help you maintain project scalability, efficiently load data from the server to the client and resolve hydration issues.

Table of Contents

  • What is Streaming in SSR
  • Lazy Loading and SSR
  • Implementing Streaming with Lazy Loading
    • Updating React Components
    • Updating the Server for Streaming
  • Server-to-Client Data
    • Passing Data on the Server
    • Handle Environment Variables on the Client
  • Hydration Issues
    • Example Scenario
    • Fixing Hydration Issues
  • Conclusion

What is Streaming in SSR

Streaming in server-side rendering (SSR) is a technique where the server sends parts of the HTML page to the browser in chunks as they are generated, rather than waiting for the entire page to be ready before delivering it. This allows the browser to start rendering content immediately, improving load times and the user's performance.

Streaming is particularly effective for:

  • Large Pages: Where generating the entire HTML could take significant time.
  • Dynamic Content: When parts of the page depend on external API calls or dynamically generated chunks.
  • High-Traffic Applications: To reduce server load and latency during peak usage.

Streaming bridges the gap between traditional SSR and modern client-side interactivity, ensuring users see meaningful content faster without compromising on performance.

Lazy Loading and SSR

Lazy loading is a technique that defers the loading of components or modules until they are actually needed, reducing the initial load time and improving performance. When combined with SSR, lazy loading can significantly optimize both server and client workloads.

Lazy loading relies on React.lazy, which dynamically imports components as Promises. In traditional SSR, rendering is synchronous, meaning the server must resolve all Promises before generating and sending the complete HTML to the browser.

Streaming resolves these challenges by allowing the server to send HTML in chunks as components are rendered. This approach enables the Suspense fallback to be sent to the browser immediately, ensuring users see meaningful content early. As lazy-loaded components are resolved, their rendered HTML is streamed incrementally to the browser, seamlessly replacing the fallback content. This avoids blocking the rendering process, reduces delays and improves perceived load times.

Implementing Streaming with Lazy Loading

This guide builds upon concepts introduced in the previous article, Building Production-Ready SSR React Applications, which you can find linked at the bottom. To enable SSR with React and support lazy-loaded components, we’ll make several updates to both the React components and the server.

Updating React Components

Server Entry Point

React’s renderToString method is commonly used for SSR, but it waits until the entire HTML content is ready before sending it to the browser. By switching to renderToPipeableStream, we can enable streaming, which sends parts of the HTML as they are generated.

// ./src/entry-server.tsx
import { renderToPipeableStream, RenderToPipeableStreamOptions } from 'react-dom/server'
import App from './App'

export function render(options?: RenderToPipeableStreamOptions) {
  return renderToPipeableStream(<App />, options)
}
Copy after login
Copy after login
Copy after login

Creating Lazy-Loaded Component

In this example, we’ll create a simple Card component to demonstrate the concept. In production applications, this technique is typically used with larger modules or entire pages to optimize performance.

// ./src/Card.tsx
import { useState } from 'react'

function Card() {
  const [count, setCount] = useState(0)

  return (
    <div className="card">
      <button onClick={() => setCount((count) => count + 1)}>
        count is {count}
      </button>
      <p>
        Edit <code>src/App.tsx</code> and save to test HMR
      </p>
    </div>
  )
}

export default Card
Copy after login
Copy after login
Copy after login

Using the Lazy-Loaded Component in the App

To use the lazy-loaded component, import it dynamically using React.lazy and wrap it with Suspense to provide a fallback UI during loading

// ./src/App.tsx
import { lazy, Suspense } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'

const Card = lazy(() => import('./Card'))

function App() {
  return (
    <>
      <div>
        <a href="https://vite.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <Suspense fallback='Loading...'>
        <Card />
      </Suspense>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  )
}

export default App
Copy after login
Copy after login
Copy after login

Updating the Server for Streaming

To enable streaming, both the development and production setups need to support a consistent HTML rendering process. Since the process is the same for both environments, you can create a single reusable function to handle streaming content effectively.

Creating a Stream Content Function

// ./server/constants.ts
export const ABORT_DELAY = 5000
Copy after login
Copy after login
Copy after login

The streamContent function initiates the rendering process, writes incremental chunks of HTML to the response, and ensures proper error handling.

// ./server/streamContent.ts
import { Transform } from 'node:stream'
import { Request, Response, NextFunction } from 'express'
import { ABORT_DELAY, HTML_KEY } from './constants'
import type { render } from '../src/entry-server'

export type StreamContentArgs = {
  render: typeof render
  html: string
  req: Request
  res: Response
  next: NextFunction
}

export function streamContent({ render, html, res }: StreamContentArgs) {
  let renderFailed = false

  // Initiates the streaming process by calling the render function
  const { pipe, abort } = render({
    // Handles errors that occur before the shell is ready
    onShellError() {
      res.status(500).set({ 'Content-Type': 'text/html' }).send('<pre class="brush:php;toolbar:false">Something went wrong
') }, // Called when the shell (initial HTML) is ready for streaming onShellReady() { res.status(renderFailed ? 500 : 200).set({ 'Content-Type': 'text/html' }) // Split the HTML into two parts using the placeholder const [htmlStart, htmlEnd] = html.split(HTML_KEY) // Write the starting part of the HTML to the response res.write(htmlStart) // Create a transform stream to handle the chunks of HTML from the renderer const transformStream = new Transform({ transform(chunk, encoding, callback) { // Write each chunk to the response res.write(chunk, encoding) callback() }, }) // When the streaming is finished, write the closing part of the HTML transformStream.on('finish', () => { res.end(htmlEnd) }) // Pipe the render output through the transform stream pipe(transformStream) }, onError(error) { // Logs errors encountered during rendering renderFailed = true console.error((error as Error).stack) }, }) // Abort the rendering process after a delay to avoid hanging requests setTimeout(abort, ABORT_DELAY) }
Copy after login
Copy after login

Updating Development Configuration

// ./server/dev.ts
import { Application } from 'express'
import fs from 'fs'
import path from 'path'
import { StreamContentArgs } from './streamContent'

const HTML_PATH = path.resolve(process.cwd(), 'index.html')
const ENTRY_SERVER_PATH = path.resolve(process.cwd(), 'src/entry-server.tsx')

// Add to args the streamContent callback
export async function setupDev(app: Application, streamContent: (args: StreamContentArgs) => void) {
  const vite = await (
    await import('vite')
  ).createServer({
    root: process.cwd(),
    server: { middlewareMode: true },
    appType: 'custom',
  })

  app.use(vite.middlewares)

  app.get('*', async (req, res, next) => {
    try {
      let html = fs.readFileSync(HTML_PATH, 'utf-8')
      html = await vite.transformIndexHtml(req.originalUrl, html)

      const { render } = await vite.ssrLoadModule(ENTRY_SERVER_PATH)

      // Use the same callback for production and development process
      streamContent({ render, html, req, res, next })
    } catch (e) {
      vite.ssrFixStacktrace(e as Error)
      console.error((e as Error).stack)
      next(e)
    }
  })
}
Copy after login
Copy after login

Updating Production Configuration

// ./server/prod.ts
import { Application } from 'express'
import fs from 'fs'
import path from 'path'
import compression from 'compression'
import sirv from 'sirv'
import { StreamContentArgs } from './streamContent'

const CLIENT_PATH = path.resolve(process.cwd(), 'dist/client')
const HTML_PATH = path.resolve(process.cwd(), 'dist/client/index.html')
const ENTRY_SERVER_PATH = path.resolve(process.cwd(), 'dist/ssr/entry-server.js')

// Add to Args the streamContent callback
export async function setupProd(app: Application, streamContent: (args: StreamContentArgs) => void) {
  app.use(compression())
  app.use(sirv(CLIENT_PATH, { extensions: [] }))

  app.get('*', async (req, res, next) => {
    try {
      const html = fs.readFileSync(HTML_PATH, 'utf-8')

      const { render } = await import(ENTRY_SERVER_PATH)

      // Use the same callback for production and development process
      streamContent({ render, html, req, res, next })
    } catch (e) {
      console.error((e as Error).stack)
      next(e)
    }
  })
}
Copy after login

Updating the Express Server

Pass the streamContent function to each configuration:

// ./server/app.ts
import express from 'express'
import { PROD, APP_PORT } from './constants'
import { setupProd } from './prod'
import { setupDev } from './dev'
import { streamContent } from './streamContent'

export async function createServer() {
  const app = express()

  if (PROD) {
    await setupProd(app, streamContent)
  } else {
    await setupDev(app, streamContent)
  }

  app.listen(APP_PORT, () => {
    console.log(`http://localhost:${APP_PORT}`)
  })
}

createServer()
Copy after login

After implementing these changes, your server will:

  • Stream HTML to the browser incrementally, reducing time to first paint.
  • Seamlessly handle lazy-loaded components, improving both performance and user experience.

Server-to-Client Data

Before sending HTML to the client, you have full control over the server-generated HTML. This allows you to dynamically modify the structure by adding tags, styles, links or any other elements as needed.

One particularly powerful technique is injecting a

Latest Articles by Author
Popular Tutorials
More>
Latest Downloads
More>
Web Effects
Website Source Code
Website Materials
Front End Template