Skip to content

Emails and other HTML

Rendering React to HTML on the server — an email, a PDF, a feed — from a server action or a route, with "use ssr".

Updated View as Markdown

An email template is a React component, and @react-email/render (or renderToString from react-dom/server) turns it into HTML. Call that from a server action and this comes back:

Error: react-dom/server is not supported in React Server Components.

React means it. Where server components render, react is a server-only build: the renderer needs the client build’s internals, and the components it would render import that same react — no useState, no useContext. Nothing that renders React to HTML can run there, and no alias fixes that. It has to run in the ssr environment, which every app here already has: the one that turns your pages into HTML for the browser.

"use ssr"

Put the rendering — the template and the call that renders it — in a module that starts with the directive:

src/lib/email/render.tsxtsx
"use ssr";

import { render } from '@react-email/render';
import { OtpEmail } from './otp-email';

export async function renderOtpEmail(code: string) {
  const email = <OtpEmail code={code} />;

  const [html, text] = await Promise.all([
    render(email),
    render(email, { plainText: true }),
  ]);

  return { html, text };
}

Everything else imports it normally:

src/lib/email/send-otp.tsts
import { renderOtpEmail } from './render';

export async function sendOtpEmail(to: string, code: string) {
  const { html, text } = await renderOtpEmail(code);

  await transporter.sendMail({ to, subject: 'Your code', html, text });
}

The module runs in the ssr environment, in the same process. Where server components render, the build replaces it with proxies of its exports that call across — the same thing "use client" does for a component, in the other direction. In development that is a call into the ssr module runner; in a build the module is part of the ssr bundle and the proxies import it from there. Nothing to configure.

The rules

Exports are async functions. A call crosses environments, so the answer is a promise. A function declared without async is refused at build with its name; so is a value, a class, export { … } or export *. Types are fine.

Pass data across, not elements. The template is created inside the module, from the arguments — renderOtpEmail(code), not render(<OtpEmail />) from the caller. An element built where server components render carries components from that side’s react, and they would render with no hooks.

The module’s imports belong to the ssr side. @react-email/components, react-dom/server, a PDF renderer, a Markdown library that uses React — all resolved and bundled for the ssr environment. A database client works there too, but belongs on the calling side; keep the module to rendering.

If you import it directly anyway

react-dom/server imported where server components render — through a library, usually — now fails with the fix in the message rather than React’s refusal:

react-dom/server cannot run where server components render … Imported by
src/lib/nodemailer.ts. Put the rendering — the template and the call — in a
module that starts with "use ssr" …

A build prints the same once, as a warning, naming the file — and the build still succeeds, because the refusal is the call’s, not the import’s: every export of the stub throws it when called, so a server does not fail to boot over an action nobody has run yet.

A dependency whose imports the build never looks inside — @react-email/render imported from an action, say — is read once for the import and warned about the same way, naming the app file and the package:

src/actions/send-otp.ts imports @react-email/render, which imports
react-dom/server, and that cannot run where server components render …
Navigation

Type to search…

↑↓ navigate↵ selectEsc close