React Email template rendered to HTML and plain text: working versions (tested 2026-10-15)
Build a transactional email with @react-email/components (preview, rows/columns, button, link) in TSX and render both the HTML document and the plain-text version with @react-email/render, checking escaping offline.
passed5 passed · 0 failed2026-10-15 20:09 UTC- Status
- passed
- Last passed
- 2026-10-15 20:09 UTC
- Tests
- 5 / 5
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38799498972
The code
Exactly the files that passed. Your agent can pull them with get_recipe("react-email-render-html-text") (install), or see them on GitHub.
{
"name": "react-email-render-html-text",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@react-email/components": "1.0.12",
"@react-email/render": "2.1.0",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15"
}
}
import { Html, Head, Preview, Body, Container, Heading, Text, Button, Hr, Link, Section, Row, Column } from "@react-email/components";
import { render } from "@react-email/render";
export type WelcomeProps = { name: string; course: string; startDate: string; url: string; items: { label: string; hours: number }[] };
export function WelcomeEmail({ name, course, startDate, url, items }: WelcomeProps) {
return (
<Html lang="en">
<Head />
<Preview>{`You're enrolled in ${course}`}</Preview>
<Body style={{ fontFamily: "Arial, sans-serif", backgroundColor: "#f4f4f4" }}>
<Container style={{ padding: "24px", backgroundColor: "#ffffff" }}>
<Heading as="h1">Welcome, {name}!</Heading>
<Text>
Your class <strong>{course}</strong> starts {startDate}.
</Text>
<Section>
{items.map((i) => (
<Row key={i.label}>
<Column>{i.label}</Column>
<Column align="right">{i.hours} h</Column>
</Row>
))}
</Section>
<Button href={url} style={{ backgroundColor: "#0b3d91", color: "#ffffff", padding: "12px 20px" }}>
View schedule
</Button>
<Hr />
<Text style={{ fontSize: "12px" }}>
Questions? <Link href="mailto:[email protected]">[email protected]</Link>
</Text>
</Container>
</Body>
</Html>
);
}
export async function renderWelcome(props: WelcomeProps) {
const el = <WelcomeEmail {...props} />;
const [html, text] = await Promise.all([render(el), render(el, { plainText: true })]);
return { html, text };
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { renderWelcome } from "../src/Welcome.tsx";
const props = {
name: "Sam <script>",
course: "CITS Orientation",
startDate: "Nov 1st",
url: "https://example.com/schedule?c=cits&x=1",
items: [{ label: "Classroom", hours: 6 }, { label: "Field", hours: 2 }],
};
test("renders a full HTML email document", async () => {
const { html } = await renderWelcome(props);
assert.match(html, /^<!DOCTYPE html PUBLIC "-\/\/W3C\/\/DTD XHTML 1\.0 Transitional\/\/EN"/);
assert.match(html, /<html dir="ltr" lang="en">/);
// React SSR puts <!-- --> markers between adjacent text nodes
assert.match(html, /Your class <strong>CITS Orientation<\/strong> starts <!-- -->Nov 1st<!-- -->\./);
assert.match(html, /href="https:\/\/example\.com\/schedule\?c=cits&x=1"/);
assert.match(html, /background-color:#0b3d91/);
});
test("user input is escaped", async () => {
const { html } = await renderWelcome(props);
assert.doesNotMatch(html, /<script>/);
assert.match(html, /Sam <script>/);
});
test("preview text is a hidden preheader", async () => {
const { html } = await renderWelcome(props);
assert.match(html, /display:none[^>]*>You('|')re enrolled in CITS Orientation/);
});
test("table layout for rows and columns", async () => {
const { html } = await renderWelcome(props);
assert.ok((html.match(/<table/g) ?? []).length >= 3);
assert.match(html, /align="right"[^>]*>6<!-- --> h/);
});
test("plain text version", async () => {
const { text } = await renderWelcome(props);
assert.match(text, /WELCOME, SAM <SCRIPT>!/);
assert.match(text, /Your class CITS Orientation starts Nov 1st\./);
assert.match(text, /View schedule https:\/\/example\.com\/schedule\?c=cits&x=1/);
assert.match(text, /^-{20,}$/m); // Hr becomes a dashed rule
assert.doesNotMatch(text, /<table|<td/);
});
{ "compilerOptions": { "jsx": "react-jsx", "module": "esnext", "moduleResolution": "bundler", "target": "es2022", "strict": true } }
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
i18next + react-i18next: plurals, interpolation and SSR rendering
Configure i18next with resources, plurals, context and escaping, and render react-i18next components to HTML on the server, with per-request instances.
Ink interactive CLI components tested with ink-testing-library
Build an interactive Ink 8 (React) terminal checklist with useInput and a timer-driven countdown, and test frames, arrow-key and space input, submit callbacks and effects with ink-testing-library.
Jotai atoms with React 19, tested with Testing Library and isolated stores
Jotai 3 primitive, derived, write-only and async atoms driving a React 19 todo UI with Suspense, tested in Vitest on happy-dom with @testing-library/react, user-event and a fresh createStore per test, plus store-only tests without React.
Next.js 16 App Router route handler + Zod validation
Validate JSON bodies and search params in a Next.js App Router route handler with Zod, returning typed 400s; tested by calling the handler with NextRequest.
All @react-email/components recipesAll @react-email/render recipesAll react recipes