Skip to main content

renderToString

renderToString is a legacy API. For new projects, use renderToPipeableStream (Node.js) or renderToReadableStream (Web Streams) instead.
Renders a React tree to an HTML string synchronously on the server.
import { renderToString } from 'react-dom/server';

const html = renderToString(reactNode, options);

Reference

renderToString(reactNode, options?)

Call renderToString to render your app to HTML on the server.
/src/server/ReactDOMLegacyServerNode.js:18-28
const html = renderToString(<App />);

Parameters

reactNode
ReactNode
required
A React node you want to render to HTML. For example, a JSX element like <App />.
options
ServerOptions
Optional server rendering options.

Returns

html
string
An HTML string with React-specific attributes like data-reactroot. This HTML can be sent to the client and hydrated with hydrateRoot.

Usage Examples

Basic Server Rendering

import { renderToString } from 'react-dom/server';
import App from './App';

function handleRequest(req, res) {
  const html = renderToString(<App />);
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>My App</title>
      </head>
      <body>
        <div id="root">${html}</div>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
}

With Client-Side Hydration

Server:
import { renderToString } from 'react-dom/server';
import App from './App';
import express from 'express';

const app = express();

app.get('*', (req, res) => {
  const html = renderToString(<App />);
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
        <title>My App</title>
        <link rel="stylesheet" href="/styles.css" />
      </head>
      <body>
        <div id="root">${html}</div>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000);
Client:
import { hydrateRoot } from 'react-dom/client';
import App from './App';

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

With Express and Template

import { renderToString } from 'react-dom/server';
import express from 'express';
import App from './App';

const app = express();

const template = (html) => `
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>React SSR App</title>
    <link rel="stylesheet" href="/static/css/main.css">
  </head>
  <body>
    <div id="root">${html}</div>
    <script src="/static/js/main.js"></script>
  </body>
</html>
`;

app.get('*', (req, res) => {
  try {
    const html = renderToString(<App />);
    res.send(template(html));
  } catch (error) {
    console.error('SSR Error:', error);
    res.status(500).send('Internal Server Error');
  }
});

app.listen(3000);

With Next.js (Legacy)

// pages/index.js
import { renderToString } from 'react-dom/server';

export default function Home({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

export async function getServerSideProps() {
  const html = renderToString(<MyComponent />);
  
  return {
    props: { html },
  };
}

Limitations

Does Not Support Suspense

renderToString does not support Suspense. Components that suspend will cause an error.
import { Suspense } from 'react';
import { renderToString } from 'react-dom/server';

// This will throw an error
try {
  const html = renderToString(
    <Suspense fallback={<div>Loading...</div>}>
      <AsyncComponent />
    </Suspense>
  );
} catch (error) {
  console.error('Suspense not supported in renderToString');
}
Solution: Use renderToPipeableStream instead:
import { renderToPipeableStream } from 'react-dom/server';

const { pipe } = renderToPipeableStream(
  <Suspense fallback={<div>Loading...</div>}>
    <AsyncComponent />
  </Suspense>,
  {
    onShellReady() {
      pipe(res);
    },
  }
);

Synchronous and Blocking

renderToString is synchronous and blocks the Node.js event loop until rendering completes. This can cause performance issues for large apps.
import { renderToString } from 'react-dom/server';

// Blocks the event loop - not ideal for production
const html = renderToString(<LargeApp />);
Solution: Use streaming APIs:
import { renderToPipeableStream } from 'react-dom/server';

// Streams HTML as it's generated - better performance
const { pipe } = renderToPipeableStream(<LargeApp />, {
  onShellReady() {
    pipe(res);
  },
});

No Streaming

Client must wait for entire HTML string before anything renders:
// Bad: Client waits for complete HTML
const html = renderToString(<App />); // 2 seconds
res.send(html); // Now client can start parsing

// Good: Client receives HTML incrementally
const { pipe } = renderToPipeableStream(<App />, {
  onShellReady() {
    pipe(res); // Start sending HTML immediately
  },
});

Migration Guide

From renderToString to renderToPipeableStream

Before:
import { renderToString } from 'react-dom/server';
import express from 'express';

const app = express();

app.get('*', (req, res) => {
  const html = renderToString(<App />);
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <body>
        <div id="root">${html}</div>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
});
After:
import { renderToPipeableStream } from 'react-dom/server';
import express from 'express';

const app = express();

app.get('*', (req, res) => {
  res.setHeader('content-type', 'text/html');
  
  const { pipe } = renderToPipeableStream(
    <html>
      <body>
        <div id="root">
          <App />
        </div>
        <script src="/client.js"></script>
      </body>
    </html>,
    {
      onShellReady() {
        pipe(res);
      },
      onError(error) {
        console.error('SSR Error:', error);
        res.statusCode = 500;
      },
    }
  );
});

Benefits of Migration

  1. Suspense Support - Async components work properly
  2. Streaming - HTML sent to client as it’s generated
  3. Better Performance - Non-blocking, scales better
  4. Selective Hydration - Interactive sooner on client
  5. Error Handling - Better error recovery

When to Use renderToString

Despite being legacy, renderToString is still useful for:
  1. Static Site Generation - Pre-rendering at build time
  2. Email Templates - Generating HTML emails
  3. Testing - Simple HTML output for snapshots
  4. Simple Apps - No Suspense, small scale

Static Site Generation Example

import { renderToString } from 'react-dom/server';
import fs from 'fs';
import path from 'path';

function buildStaticSite() {
  const pages = [
    { path: '/', component: <Home /> },
    { path: '/about', component: <About /> },
    { path: '/contact', component: <Contact /> },
  ];
  
  pages.forEach(({ path: pagePath, component }) => {
    const html = renderToString(component);
    const fullHtml = `
      <!DOCTYPE html>
      <html>
        <head><title>My Site</title></head>
        <body>
          <div id="root">${html}</div>
        </body>
      </html>
    `;
    
    const filePath = path.join('dist', pagePath, 'index.html');
    fs.mkdirSync(path.dirname(filePath), { recursive: true });
    fs.writeFileSync(filePath, fullHtml);
  });
}

buildStaticSite();

TypeScript

import { renderToString } from 'react-dom/server';
import type { ReactElement } from 'react';

interface ServerOptions {
  identifierPrefix?: string;
}

function renderPage(component: ReactElement): string {
  const html: string = renderToString(component, {
    identifierPrefix: 'app-',
  });
  
  return `
    <!DOCTYPE html>
    <html>
      <body>
        <div id="root">${html}</div>
      </body>
    </html>
  `;
}

const markup = renderPage(<App />);

Error Handling

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

function renderWithErrorHandling(component) {
  try {
    const html = renderToString(component);
    return { success: true, html };
  } catch (error) {
    console.error('Server rendering error:', error);
    
    // Render error page
    const errorHtml = renderToString(<ErrorPage error={error} />);
    return { success: false, html: errorHtml };
  }
}

const result = renderWithErrorHandling(<App />);

if (result.success) {
  res.send(result.html);
} else {
  res.status(500).send(result.html);
}

Performance Considerations

Measure Rendering Time

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

function measureRenderTime(component) {
  const start = Date.now();
  const html = renderToString(component);
  const duration = Date.now() - start;
  
  console.log(`SSR took ${duration}ms`);
  
  return html;
}

Optimize Large Apps

import { renderToString } from 'react-dom/server';
import { cache } from './cache';

function renderWithCache(component, cacheKey) {
  // Check cache first
  const cached = cache.get(cacheKey);
  if (cached) return cached;
  
  // Render and cache
  const html = renderToString(component);
  cache.set(cacheKey, html, { ttl: 60 * 1000 }); // 1 minute
  
  return html;
}

Common Pitfalls

Don’t use browser APIs

// Bad: window not available on server
function Component() {
  const width = window.innerWidth; // Error!
  return <div>Width: {width}</div>;
}

// Good: Check environment
function Component() {
  const width = typeof window !== 'undefined' ? window.innerWidth : 0;
  return <div>Width: {width}</div>;
}

Don’t forget to escape user content

import { renderToString } from 'react-dom/server';
import he from 'he';

// Bad: XSS vulnerability
function renderPage(userContent) {
  return `
    <div id="content">${userContent}</div>
  `;
}

// Good: React escapes by default
function renderPage(userContent) {
  const html = renderToString(
    <div id="content">{userContent}</div>
  );
  return html;
}