Core concepts

Rendering

render() turns a template into HTML and plain text. Its result and options, validate() and outline(), merge tags, and where it runs.

render() turns a template into the output its root asks for. validate() runs the same checks without writing anything, and outline() prints the layout as text.

import { render } from "@hyper-elements/react";

const { html, text, head, body, bodyStyle, diagnostics } = await render(<Receipt />);

The examples on this page use this receipt:

import { Button, Column, Email, Heading, Image, Link, List, ListItem, MergeTag, Text, Row, Span, Table, TableCell, TableRow } from "@hyper-elements/react";

export default function Receipt() {
  return (
    <Email previewText="Your Acme receipt" style={{ backgroundColor: "#f4f4f5", fontFamily: "Inter, Arial, sans-serif", gap: 16 }}>
      <Image src={{ src: "https://example.com/logo.png", width: 240, height: 80 }} alt="Acme" style={{ width: 120 }} />
      <Column style={{ backgroundColor: "#ffffff", padding: 32, gap: 16, borderRadius: 8 }}>
        <Heading level={1} style={{ fontSize: 24 }} mobile={{ fontSize: 20 }}>
          Thanks for your order, <MergeTag name="first_name" />
        </Heading>
        <Text>
          Order <Span style={{ fontWeight: 700 }}>#10482</Span> ships tomorrow. <Link href="https://example.com/orders/10482">Track it</Link>.
        </Text>
        <Table style={{ cellPadding: [8, 0], rowBorder: "1px solid #e4e4e7" }}>
          <TableRow header>
            <TableCell>Item</TableCell>
            <TableCell style={{ textAlign: "right" }}>Price</TableCell>
          </TableRow>
          <TableRow>
            <TableCell>Linen shirt</TableCell>
            <TableCell style={{ textAlign: "right" }}>$48.00</TableCell>
          </TableRow>
        </Table>
        <Row style={{ gap: 16 }}>
          <Column>
            <Text style={{ fontWeight: 700 }}>Shipping to</Text>
            <Text>Ada Lovelace, London</Text>
          </Column>
          <Column>
            <Text style={{ fontWeight: 700 }}>Next steps</Text>
            <List type="number">
              <ListItem>We pack it</ListItem>
              <ListItem>You track it</ListItem>
            </List>
          </Column>
        </Row>
        <Button href="https://example.com/orders/10482" style={{ backgroundColor: "#111827", borderRadius: 6 }} hover={{ backgroundColor: "#374151" }}>
          View your order
        </Button>
      </Column>
      <Text style={{ fontSize: 12, color: "#71717a", textAlign: "center" }}>Acme Inc., 1 Market St, San Francisco</Text>
    </Email>
  );
}

render()

render(element, options?) is async. It resolves to:

Field What it is
html The whole document, with no React hydration markers and no client-side JavaScript
text The plain-text version, from the same template
head What goes in <head>: meta tags, the title, the stylesheet
body What goes in <body>
bodyStyle The body’s own inline style
diagnostics Warnings and notes. Errors aren’t here: they make render() reject

head, body and bodyStyle are for embedding the output in a page of your own. See Page.

A template with errors doesn’t render. render() rejects with an ElementsError, whose diagnostics give each problem’s code, message and path:

import { ElementsError, render } from "@hyper-elements/react";

try {
  await render(<Receipt />);
} catch (error) {
  if (error instanceof ElementsError) {
    for (const { code, message, path } of error.diagnostics) console.error(code, path, message);
  }
  throw error;
}

The plain-text version

text is written from the same template, so it never drifts from the HTML. Headings are in capitals, links and buttons show their URL in brackets, list items get their numbers (or a -), a table’s cells are joined with |, and an image shows its alt text. The receipt’s:

[Acme]

THANKS FOR YOUR ORDER, {{first_name}}

Order #10482 ships tomorrow. Track it (https://example.com/orders/10482).

Item | Price
Linen shirt | $48.00

Shipping to

Ada Lovelace, London

Next steps

1. We pack it
2. You track it

View your order (https://example.com/orders/10482)

Acme Inc., 1 Market St, San Francisco

The preview text isn’t in it.

Options

Option Default What it does
pretty false Indents the HTML, for reading it. For sending, keep the compact HTML: white space may change how some email clients show it
mergeTag (name) => `{{name}}` A function from a merge tag’s name to how it’s written
classPrefix "e" What every class name in the stylesheet starts with, for HTML embedded where other CSS is. A Document’s page boxes keep the class elements-page
hideBelow Where the phone styles start In an Email, the window width in px at and below which what’s hidden on phones is hidden
const { html } = await render(<Receipt />, { pretty: true, classPrefix: "acme-" });

Merge tags

A MergeTag is a value your sending service fills in, written in that service’s syntax:

<Text>
  Hi <MergeTag name="first_name" />, your order has shipped.
</Text>

By default it’s written as {{first_name}}, in the HTML and in the plain text. For another syntax, pass mergeTag:

// Mailchimp-style: *|FIRST_NAME|*
await render(<Receipt />, { mergeTag: (name) => `*|${name.toUpperCase()}|*` });

// Liquid with spaces: {{ first_name }}
await render(<Receipt />, { mergeTag: (name) => `{{ ${name} }}` });

What mergeTag returns goes into the HTML as it is, with the name in it escaped, so a syntax with < in it (<%= name %>) works there too.

A link can be a merge tag as well: href takes a URL or a tag, so <Link href="{{unsubscribe_url}}"> writes href="{{unsubscribe_url}}".

Tags in your text

Text you write is escaped, and tags without <, > or & in them pass through unchanged: {{code}}, *|FNAME|* and {% if vip %} reach the HTML and the plain text as you wrote them. A tag with < in it, written as text, is escaped in the HTML (&lt;%= name %&gt;). Write those with MergeTag and a mergeTag function.

validate()

validate(element) runs the checks render() runs, except Gmail’s size limits, without writing anything. What’s wrong with the template comes back in diagnostics, not as an error:

import { validate } from "@hyper-elements/react";

const { valid, diagnostics } = validate(<Receipt />);

valid is false when there’s an error. Warnings and notes leave it true. It checks props, styles, children and ids, expands your custom components, runs the output’s checks and the layout, and reports what the output can’t show as asked. Only what needs the written HTML is left to render(): an email’s size against Gmail’s limits. It’s synchronous. See Diagnostics.

outline()

outline(element) prints the laid-out template: each block on a line under what holds it, with its width on desktop and what it shows (a heading’s words, a link’s URL, an image’s alt text), and whether a Row stacks on phones. Use it to check a layout without reading HTML, in a test or from an agent.

import { outline } from "@hyper-elements/react";

console.log(outline(<Receipt />));
Email 600px: background #f4f4f5
  Image 120px [Acme] https://example.com/logo.png
  Column 600px: background #ffffff
    Heading h1 536px "Thanks for your order, {{first_name}}"
    Text 536px "Order #10482 ships tomorrow. Track it (https://example.com/orders/10482)."
    Table 536px
      TableRow 536px
        TableCell 268px "Item"
        TableCell 268px "Price"
      TableRow 536px
        TableCell 268px "Linen shirt"
        TableCell 268px "$48.00"
    Row 536px: 2 side by side, stacked on phones
      Column 260px
        Text 260px "Shipping to"
        Text 260px "Ada Lovelace, London"
      Column 260px
        Text 260px "Next steps"
        List 260px
          ListItem 236px "We pack it"
          ListItem 236px "You track it"
    Button: background #111827 "View your order" → https://example.com/orders/10482
  Text 600px "Acme Inc., 1 Market St, San Francisco"

Like render(), it throws an ElementsError when the template has errors.

The same input, the same output

A template renders the same every time. Rules the inline styles can’t express (phone styles, hiding, hover) go in a stylesheet under short classes named after the rule’s content, like .e3k9fa. The same rule always gets the same class, and the HTML gets no generated ids, only the ids you write. Snapshot tests stay stable, and diffs show real changes.

renderPdf() and renderImage() hold to the same rule: the same template writes the same bytes.

Where it runs

render() runs on your server, with no network calls. It works in plain Node.js and in Next.js route handlers, Server Components and Client Components’ server render. Rendering is one React render that collects the template into a tree, lays it out, and writes it, and that render needs no React context, which React’s server build doesn’t have.

@hyper-elements/react/pdf and @hyper-elements/react/image run in Node.js only.

Tip: Fetch your data before you render, and pass it in as props. A component that’s async or suspends is an error, since render() doesn’t wait for it.