Skip to content
Back to Guides

React Email

Updated

Send React Email templates with Lumail

React Email handles the template layer: components, email-safe markup and a local preview server. Lumail handles sending: your verified domain, delivery tracking and the subscriber record. The bridge between them is one HTML string.

TL;DR

Install lumail, @react-email/components and @react-email/render. Write the template as a component, call await render(<Template {...props} />) to get HTML and await render(..., { plainText: true }) for the text part, then pass both to lumail.emails.send({ from, to, subject, html, text }). Lumail does not accept a react field, so always render first.

Prerequisites

  • A Node.js 20+ project that can compile TSX on the server (Next.js, TanStack Start, Remix, or a plain TypeScript setup).
  • A Lumail API token that starts with lum_.
  • A sending domain verified in the same Lumail organization as the token.

1. Install the packages

Terminal
npm install lumail @react-email/components @react-email/render npm install -D react-email

2. Set your environment variables

Rendering happens on your server, next to the send call, so the token lives in the same server environment. Never render and send from the browser.

.env
# Server-side only. Create it in Lumail under Settings > API Tokens. LUMAIL_API_KEY=lum_your_api_token

3. Write the template

Keep templates in an emails/ folder. Props make them reusable: the same component renders every customer's receipt.

Use the React Email primitives instead of raw divs. They output the table-based markup and inline styles email clients still need.

emails/welcome.tsx
import { Body, Button, Container, Head, Heading, Html, Preview, Text, } from "@react-email/components"; type WelcomeEmailProps = { name: string; dashboardUrl: string }; export default function WelcomeEmail({ name, dashboardUrl }: WelcomeEmailProps) { return ( <Html> <Head /> <Preview>Your Acme account is ready</Preview> <Body style={{ backgroundColor: "#f6f6f6", fontFamily: "Arial, sans-serif" }}> <Container style={{ maxWidth: "560px", padding: "24px", backgroundColor: "#ffffff" }}> <Heading style={{ fontSize: "22px" }}>Welcome, {name}</Heading> <Text>Your account is ready. Start with your dashboard.</Text> <Button href={dashboardUrl} style={{ backgroundColor: "#111111", color: "#ffffff", padding: "12px 20px", borderRadius: "6px" }} > Open the dashboard </Button> </Container> </Body> </Html> ); } WelcomeEmail.PreviewProps = { name: "Ada", dashboardUrl: "https://acme.com/app" };

4. Preview it locally

The react-email dev dependency ships a preview server that hot-reloads every template in the folder. PreviewProps on the component fills it with sample data.

package.json
{ "scripts": { "email:dev": "email dev --dir emails" } }

5. Render to HTML and send

render is async and returns a string. Render twice: once for HTML and once with plainText: true for the text part, which keeps the plain-text version faithful to your copy.

This file contains JSX, so give it a .tsx extension. Run it only on the server.

lib/send-welcome.tsx
import { render } from "@react-email/render"; import { Lumail } from "lumail"; import WelcomeEmail from "../emails/welcome"; const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY ?? "" }); export async function sendWelcome(user: { id: string; email: string; name: string }) { const email = <WelcomeEmail name={user.name} dashboardUrl="https://acme.com/app" />; const [html, text] = await Promise.all([ render(email), render(email, { plainText: true }), ]); const { data, error } = await lumail.emails.send( { from: "Acme <[email protected]>", to: user.email, subject: "Your Acme account is ready", html, text, }, { idempotencyKey: `welcome:${user.id}` }, ); if (error) throw new Error(`${error.name}: ${error.message}`); return data.id; }

Handle errors

Rendering errors are thrown by React like any other render error, before Lumail is called. API errors come back from the SDK as { data: null, error }, never thrown, so check error explicitly.

If Lumail rejects the body with validation_error, make sure you pass html and not a react element. Lumail rejects the react, template and attachments fields.

Lumail send errors
Statuserror.nameWhat to do
401missing_api_keyMissing or invalid token. Check the env var on the server that sends.
403missing_permissionThe token lacks the emails permission. Mint one that has it.
402plan limitThe organization used its plan's email volume. Upgrade or wait for the next period.
4xxvalidation_errorMissing field, more than one body format, or a from domain that is not verified.
429RECIPIENT_RATE_LIMITEDThat mailbox hit 5 sends in 10 minutes or 20 in 24 hours. Honor Retry-After.
nonenetwork_error / timeoutThe request never got an answer. Safe to retry with the same idempotency key.

Make retries safe with idempotency

Rendering is deterministic for the same props, so retrying with the same idempotency key is safe: Lumail returns the email it already queued and ignores the second body.

Verify your sending domain

Lumail only sends from a domain you have verified. Add the domain in your organization's Domains settings, then publish the SPF, DKIM and DMARC records it shows at your DNS provider. Until the domain verifies, every send fails with an error saying the domain is not authorized or verified.

Use a subdomain such as mail.yourdomain.com if your root domain already sends from another provider. Start DMARC at p=none, then tighten it once reports look clean.

Production checklist

  • Each template sets a <Preview> line, which inbox lists show next to the subject.
  • Templates are tested in the preview server at mobile width and in dark mode before shipping.
  • The from address is on a domain verified in the same organization as the token, with SPF, DKIM and a DMARC record.
  • LUMAIL_API_KEY is set on every environment that sends, never committed, never in a public or client-side variable. Use one token per environment.
  • Every send that can be retried (webhooks, queues, jobs) passes an idempotency key derived from the triggering event.
  • One-time codes and magic links set tracking: { links: false, open: false } so links are not rewritten.
  • End-to-end tests send to fixture addresses such as [email protected], which return an id without sending real email.
  • A failed email is logged with error.name and never fails the user's request on its own.

Frequently asked questions

Can I pass a React component straight to Lumail?

No. Lumail accepts html, markdown or tiptap, and rejects a react field. Render the component to an HTML string with render from @react-email/render and send that string.

Is render from @react-email/render synchronous?

No. Current versions return a Promise, so await it. Pass { plainText: true } as the second argument to get the plain-text version of the same template.

Do I need to send a plain-text version?

It is good practice. If you omit text, Lumail generates it from your HTML. Rendering it yourself with plainText: true gives you control over how buttons and links read in text-only clients.

Can I use React Email templates for Lumail campaigns?

Campaigns use Lumail's own editor and Markdown, which keep unsubscribe links, segments and campaign analytics correct. Use React Email for transactional email sent through the API: receipts, password resets, invites and notifications.

Does this work with Next.js and TanStack Start?

Yes. Render and send inside a route handler, server action, server function or server route. The framework guides show where the send call belongs in each one.

Keep building

Send your first email from code today.

3,000 emails a month free. Transactional and marketing email on one verified domain, with unlimited subscribers on every plan.