Skip to content
Chapter 49Lesson 2

The preview server loop

Iterate on React Email templates in the local preview server, then verify them with a real test-send before shipping.

The last lesson left you with a shippable emails/welcome.tsx and three open questions: does the heading wrap awkwardly at 600px, is the button readable in dark mode, is the preheader what you intended? You’ve read the template as code but haven’t seen it render.

The only place those answers are real is the inbox: an actual mail client rendering your actual message, not your editor or a browser tab. That was the lesson of the flex trap, where Chrome renders the JSX one way and Gmail renders the wire HTML another, and Chrome shows you the wrong result.

But treating the inbox as the source of truth can’t mean sending from staging, alt-tabbing to Gmail, changing one word, and repeating. That loop is slow, clutters a real inbox, burns your send quota, and still shows you only one client out of a dozen.

So you work with two tools. This lesson installs both and shows when each can be trusted:

  • A local preview server, the fast inner loop. Save a file and watch it re-render in under a second. This is where you do most of the work, catching layout and content problems.
  • A test-send, the verification gate. A real message to inboxes you actually open, run once at the end to confirm what no local preview can fake.

By the end you’ll have a pnpm email dev script, a running preview server, and a loop you run on every template before it ships. Don’t treat the fast preview as authoritative: the moment you forget it isn’t is the moment a broken dark-mode button reaches a customer. Keep the line between iterate and verify clear and the rest follows.

  1. Add a script to package.json. The react-email package ships a command-line binary called email, and this one line exposes its subcommands (dev, build, export) through pnpm email ….

    package.json
    {
    "scripts": {
    "email": "email"
    }
    }
  2. Install the preview UI as a dev dependency. The react-email package ships the email CLI but not the preview interface, which lives in @react-email/ui. Add it explicitly so the next command boots straight to the server, rather than stopping to ask whether to install it first, a prompt that would hang a CI run with no one to answer it.

    Terminal window
    pnpm add -D @react-email/ui
  3. Run it.

    Terminal window
    pnpm email dev

    This boots a local server at http://localhost:3000, scans your emails/ directory, lists every template in a left-hand panel, and renders the one you select. Open the URL and click welcome.tsx: there is the template you wrote last lesson, rendered.

The server renders your template using its PreviewProps. Last lesson you co-located mock data beside the component:

WelcomeEmail.PreviewProps = {
firstName: 'Ada',
verifyUrl: 'https://yourapp.com/verify/abc123',
} satisfies WelcomeEmailProps;

A template is a pure function of its props, so with no values there is nothing to render. PreviewProps supplies them, so the preview greets “Ada” and points the button at a plausible verify URL. Making the template props-only pays off here: the same file that renders in the preview ships in production, with real values swapped in for Ada’s.

Your lesson-1 template, rendered. The server reads PreviewProps — that’s where the name Ada and the verify URL come from.

Now the habit that makes this valuable: keep the server running in a second terminal whenever you touch a template. It has a file watcher , so when you save a .tsx the preview hot-reloads on its own. The inner loop becomes save, alt-tab, eyeball, fix, measured in seconds rather than minutes.

The server reads each PreviewProps field and surfaces it as an editable control. Change firstName in that panel and the preview re-renders with the new value, no code edit required.

That turns PreviewProps into a set of dials, so use it for the cases you’d otherwise never see. Don’t stop at the happy path of firstName: 'Ada'. Stressing the layout through the panel is far faster than re-running your app’s Server Action with new inputs:

  • Set firstName to something long like Maximilian-Alexander. Does the heading wrap cleanly onto a second line, or overflow the column?
  • Set verifyUrl to a genuinely long URL; a real signed token can run a couple hundred characters. Does the button stay in the column, or does the link break the layout?
  • Set a near-empty value to see how the template behaves when the data is thinner than you assumed.
Editing firstName in the props panel re-renders instantly — a faster way to stress-test wrapping than re-running the real send path.

Keep PreviewProps honest: the values should look like a real production payload, a name shaped like a name and a URL shaped like your verify URL, not firstName: 'x'. If your mock URL is ten characters and production sends two hundred, the preview will look perfect and the inbox will look broken.

The three view switches: desktop, mobile, and dark

Section titled “The three view switches: desktop, mobile, and dark”

The preview’s toolbar has three switches, each a different view of the same welcome.tsx under different conditions. Click through the tabs.

The baseline — your template at its roomiest.

The first switch toggles between desktop and mobile width. Make mobile a required step in the loop: more than half of email opens happen on phones, and a template that wraps cleanly in a 600px desktop column can stack into something illegible at around 375px. Two columns collapse together, a comfortable button runs edge to edge, padding you tuned for desktop swallows the screen. None of that shows in the desktop view, so always toggle to mobile before you call a template done.

The second switch renders your template as though the reader’s OS were set to dark, under prefers-color-scheme: dark . But it is not authoritative. Real mail clients don’t simply honor your dark styles; many apply their own color inversion on top of whatever you send. Apple Mail does one thing, Gmail on Android another, and neither matches the preview. As React Email’s docs put it, the toggle emulates client inversion, it doesn’t reproduce it.

So the toggle catches the obviously broken case cheaply: a near-white logo that vanishes against an inverted background, body text that turns invisible, a button that loses all contrast. But treat dark mode as confirmed only after a real test-send to a real client. The toggle narrows the problem; it doesn’t close it. Building dark-mode styles that survive a real client is the next lesson’s subject.

Beyond the visual render, the preview UI exposes two views of the output, the artifact your code produces. Each earns a specific glance in the loop.

The HTML view shows the literal markup that goes on the wire, where you can confirm what the visual render hides:

  • That your <Tailwind> classes compiled to inline styles: class="text-lg" has become style="font-size: 18px; …" on the element.
  • That your <Preview> preheader text is present, sitting where the inbox will pull it from.
  • That the <head> carries what it should.

When a brand color mysteriously disappears in a real client, this is the first place you look, to check whether the style inlined at all.

The plain-text view shows the text/plain half of the message, which the Resend SDK derives automatically from your React node. The glance here is different: don’t just confirm it exists, read it. It should read as a coherent standalone message, not the HTML with the tags torn off. This part serves a real audience: screen readers, clients that strip HTML, and the fallback Gmail shows when it clips a long message. A garbled text part fails all of them silently.

welcome.tsx → the HTML on the wire
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "…">
<html lang="en" dir="ltr">
<head>
3 collapsed lines
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<div style="display:none;overflow:hidden;line-height:1px;max-height:0;opacity:0">
Confirm your email to finish setting up YourApp
</div>
<body style="background-color:#f4f4f5">
<table align="center" width="100%" role="presentation" cellpadding="0"
cellspacing="0" style="max-width:600px;margin:0 auto">
<tbody>
<tr>
<td style="padding:32px 24px">
<p style="font-weight:700;font-size:18px;color:#4f46e5;margin:0 0 24px">
YourApp
</p>
<h1 style="font-size:24px;line-height:32px;font-weight:600;color:#18181b;margin:0 0 16px">
Welcome, Ada!
</h1>
<p style="font-size:16px;line-height:24px;color:#3f3f46;margin:0 0 24px">
Thanks for signing up. Confirm your email address to get started.
</p>
<a href="https://yourapp.com/verify/abc123"
style="background-color:#4f46e5;color:#ffffff;border-radius:6px;
padding:12px 20px;font-weight:600;text-decoration:none;display:inline-block">
Verify email
</a>
<p style="font-size:12px;line-height:16px;color:#a1a1aa;margin:32px 0 0">
YourApp Inc · 2500 Mission St, San Francisco, CA · Need help? support@yourapp.com
</p>
</td>
</tr>
</tbody>
</table>
</body>
</html>

The pipeline’s output made literal: every <Tailwind> class compiled to an inline style, the <Preview> preheader hidden at the top of the body, the layout built from a <table>, not a flex row.

The text part has a full discipline behind it, how to keep it coherent and the accessibility checklist it has to pass, and that’s the next lesson’s territory. Here, you only need to know the tab exists and that you should read it.

The preview server also serves anything you drop in emails/static/ at /static/…, so you can preview a logo locally without hosting it first. In production, last lesson’s rule still governs: a real send needs images at an HTTPS URL.

Everything so far is the inner loop: fast, local, good at catching layout and content. The test-send is the other half, and the half people skip.

The preview UI has a Send button. It fires a real email through Resend, using the same RESEND_API_KEY your app uses, to an address you type in. This is the verification gate, not another preview, and it exists because the inner loop cannot show you what a real client does with your message.

Keep the two jobs distinct:

  • The loop (the preview server) is where you iterate: fast and local, it catches the 600px wrap and the empty preheader.
  • The test-send is where you verify: slow and real, the only way to see the dark-mode inversion the toggle could only emulate and the Outlook VML button path nothing local can reproduce.

When a template clears the loop, test-send it to a spread of accounts you actually open (a personal Gmail, an iCloud or Apple Mail address, an Outlook.com, a Proton) and open each in its real client. That spread is what catches what the dark toggle can’t, like Gmail Android’s blanket inversion and Outlook’s VML button. The test-send is the gate the template passes before it’s wired into a Server Action and shipped.

The gate — a real send through Resend to an inbox you actually read.

The preview-versus-gate distinction is the idea this lesson is built on, so test it before moving on. Mark each statement true or false.

Each claim is about what the preview server proves and what only a test-send can. Mark each statement True or False.

If the dark-mode toggle in the preview looks correct, the email is confirmed correct in dark mode on every client.

The toggle only emulates prefers-color-scheme: dark. Real clients like Apple Mail and Gmail Android apply their own color inversion the preview can’t reproduce — dark mode is confirmed only by a test-send.

The preview server’s file watcher hot-reloads the template when you save, so you don’t restart it per change.

That’s the whole value of the inner loop — save, alt-tab, eyeball, fix, all without restarting the server.

A test-send to an inbox you never open still verifies cross-client rendering.

A test-send only proves anything if you open it in the real client. A dead address confirms the send succeeded, not that the message rendered.

The preview server is the fast iteration loop; the test-send is the final verification gate.

This is the core distinction. Iterate in the loop, then pass the gate once before shipping.

A flex layout that renders fine in the preview will render fine in Gmail.

The preview uses a Chrome renderer, which understands flex — Gmail collapses it to default block flow. This is exactly why the test-send exists: the browser lies about what the inbox will do.

Here is the whole loop as one chain. Each step catches a class of failure the one before it cannot, so none is skippable.

Start emails/welcome.tsx exists, but you've only read it as code.
The starting point — code on disk, still unseen. Every later step exists to see what this one can't.
Unlocks A template is a pure function of its props — no values, nothing to show.
Without realistic mock data the preview has nothing to render. This is the step that makes the rest of the loop possible.
Catches the gap between code and a render — save, and it hot-reloads in a second.
Turns code-on-disk into something you can look at — and the file watcher catches your next save automatically.
Catches layout — the 600px wrap, the 375px stack, the broken-dark case.
Catches layout: the 600px desktop wrap, the 375px mobile stack, and the obviously-broken dark case the static code can't reveal.
Catches an incoherent text part the visual render hides entirely.
Catches the text-part incoherence the visual render hides — the half of the message screen readers and HTML-stripping clients actually get.
Gate what the preview can't fake — real client inversion, Outlook VML.
Catches what the preview can't fake: a real client's dark-mode inversion and Outlook's VML button path. This is the verification gate.
Ship sendEmail({ react: <WelcomeEmail … /> }) — verified, now live.
The template, now verified, goes live behind real props — wired into the Server Action with the sendEmail wrapper from the last chapter.

In words:

  1. Write the template in emails/welcome.tsx.
  2. Define realistic PreviewProps, or the preview has nothing to render.
  3. Run pnpm email dev in a side terminal.
  4. Save and eyeball the render: desktop, mobile, dark.
  5. Read the plain-text view and confirm it’s coherent.
  6. Test-send to two real inboxes, one Gmail and one Apple Mail, to catch client differences.
  7. Wire it into the Server Action with sendEmail({ react: <WelcomeEmail … /> }).

Both pnpm dev (your Next.js app) and pnpm email dev (the preview server) default to port 3000, so starting the second one makes it refuse to boot or land somewhere unexpected. Move the email server, not the app, since the app’s URLs are baked into more config.

Terminal window
pnpm email dev --port 3001

-p is the short form of --port.

The React Email CLI docs are the canonical reference for the preview server and its commands.