Skip to content
Chapter 49Lesson 2

The preview server loop

How to iterate on React Email templates with the local preview server and verify them with a real test-send before they ship.

The last lesson left you holding 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 actually what you intended? You’ve read that template as code, but you haven’t seen it render.

The honest answer to all three is that the only place those answers are truly real is the inbox: an actual mail client rendering your actual message, not your editor and not a browser tab. This is the lesson the flex trap taught you. Chrome renders the JSX one way, Gmail renders the wire HTML another, and Chrome is the one giving you the wrong picture.

But “the inbox is the only source of truth” can’t mean sending from staging, alt-tabbing to Gmail, refreshing, squinting, changing one word, and repeating. That loop is slow, it pollutes a real inbox with junk, it burns your send quota, and even then it shows you only one client out of a dozen. You’d never iterate on a web page that way, and you’d never tolerate a five-minute round trip just to check whether a heading wraps.

So you work with two tools. This lesson installs both and, just as important, shows you exactly when each one can be trusted:

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

By the end of this lesson you’ll have a pnpm email dev script, a running preview server, and a repeatable loop you run on every template before it ships. The mistake to avoid is conflating the two tools and treating the fast preview as if it were authoritative. It isn’t, and the moment you forget that is the moment a broken dark-mode button reaches a customer. Keep the line between iterate and verify clear and the rest of the lesson follows from it.

The setup is two steps and one command, so start there.

  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 server’s UI as a dev dependency. The react-email package ships the email CLI but not the preview UI. That lives in @react-email/ui, and it isn’t pulled in for you. Add it explicitly so the next command boots straight to the server instead of stopping to ask whether to install it first. That same prompt would hang a CI run, where no one is there 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 it finds 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.

This is the bridge between the two lessons, so it’s worth pausing on. The server renders your template using its PreviewProps. Recall that last lesson you co-located mock data right beside the component:

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

That block is not decoration. It is the reason the server can show you anything at all. A template is a pure function of its props, so with no values there is nothing to render. PreviewProps supplies those values, so the preview greets “Ada” and points the button at a plausible verify URL. The work you did making the template props-only pays off here: the same file that renders in the preview is the one that ships in production, just 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.

Here is the habit that makes the whole thing valuable: keep this server running in a second terminal whenever you touch a template. The server isn’t a one-shot render. It has a file watcher , so when you save a .tsx the preview hot-reloads on its own. The inner loop is then just save, alt-tab, eyeball, fix, measured in seconds rather than minutes. That difference between seconds and minutes is the entire reason this tool exists.

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

This is small to describe and large in value, because it turns PreviewProps from a fixed snapshot into a live what-if surface. The experienced move is to use it for the cases you’d otherwise never see. Don’t just admire the happy path with firstName: 'Ada'. Stress the layout through the panel, which is far faster than re-running your app’s Server Action with different inputs:

  • Swap firstName for something long like Maximilian-Alexander. Does the heading wrap cleanly onto a second line, or does it overflow the column?
  • Swap verifyUrl for a genuinely long URL. A real signed token can run a couple hundred characters. Does the button stay inside the column, or does the link break the layout?
  • Try a sparse or nearly 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.

One caution belongs right here, at the point where it matters: keep PreviewProps honest. The values should look like the real production payload, a name shaped like a name and a URL shaped like your actual verify URL, not firstName: 'x'. A PreviewProps that doesn’t resemble a real send teaches you nothing about the real send. If your mock URL is ten characters and production sends two hundred, the preview will look perfect and the inbox will look broken. The panel is only as truthful as the data you seed it with.

The preview chrome gives you three switches. The clean way to think about them is as three lenses on the same template: the identical welcome.tsx, viewed under different conditions. Here they are side by side, so click through the tabs.

The baseline — your template at its roomiest.

The first switch is the desktop/mobile viewport toggle. Treat the mobile view as a required step in the loop, because the numbers demand it. Most consumer email opens happen on phones, with recent email-client surveys putting it well past half, and a template that wraps beautifully at a 600px desktop column can stack into something illegible at around 375px. Two columns collapse into a cramped sandwich, a button that was comfortable now runs edge to edge, and padding you tuned for desktop swallows half the screen. None of that shows up in the desktop view. The rule is simple: always toggle to mobile before you call a template done.

The second switch is the dark-mode toggle, which renders your template as though the reader’s OS were set to dark. This is the most important judgment call in the lesson, so slow down for it.

The toggle renders under prefers-color-scheme: dark , but it is not authoritative, and the reason matters. Real mail clients don’t simply honor your dark styles. Many apply their own color inversion heuristics on top of whatever you send. Apple Mail does one thing, Gmail on Android does another, and neither matches the preview’s emulation. React Email’s own documentation is careful with the wording: the toggle emulates client inversion. Emulates, not reproduces.

So calibrate your trust accordingly. The toggle is excellent for catching the obviously broken case: a near-white logo that vanishes against an inverted near-white background, body text that turns invisible, a button that loses all contrast. Catch those here, cheaply. But treat dark mode as confirmed only after a real test-send to a real client. The toggle narrows the problem; it does not close it.

That is the whole job of this section: what the toggle shows and how far to trust it. Building dark-mode styles that survive a real client is a discipline of its own, and it’s the subject of the next lesson. For now, catch the obvious here and confirm the rest at the gate.

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

The HTML view shows the literal markup that goes on the wire. This is where last lesson’s render pipeline stops being an abstraction and becomes something you can see. Open it and you can confirm the things the visual render hides:

  • That your <Tailwind> classes actually compiled to inline styles. You’ll see class="text-lg" has become style="font-size: 18px; …" on the element, the inlining the pipeline promised, made literal.
  • That your <Preview> preheader text is present in the document, sitting where the inbox will pull it from.
  • That the <head> carries what it should.

Think of the HTML view as the point where you check whether the pipeline is doing what it promised. When a brand color mysteriously disappears in a real client, this is the first place you look, to see whether the style inlined at all.

The plain-text view shows the text part, the text/plain half of the message that the Resend SDK derives automatically from your React node, the one you got for free last lesson. The glance here is different in kind: don’t just confirm it exists, actually read it. Confirm it reads as a coherent standalone message and not as 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 sitting hidden at the top of the body, and the layout built from a <table>, not a flex row.

This section’s job is only to point at the two views and say what to glance at. The text part has a full discipline behind it, including 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.

One useful connection while you’re in these views: the preview server also serves anything you drop in emails/static/ at /static/…, so you can preview a logo locally without hosting it first. That helps in the loop, but last lesson’s rule still governs production, where a real send needs images at a real HTTPS URL. The emails/static/ folder is a dev convenience, not a send-time CDN.

Everything so far has been the inner loop: fast, local, catching layout and content. Now for the other half of the lesson, the half people skip at their peril.

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 not another preview. It’s the verification gate, and it exists precisely because the inner loop has a ceiling it cannot break through.

Keep the two jobs distinct in your head:

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

The standard practice is this: when a template clears the loop, test-send it to a spread of real accounts you actually open (a personal Gmail, an iCloud or Apple Mail address, an Outlook.com, a Proton) and open each one in its real client. That spread is what catches the things the preview’s dark toggle can’t, like Gmail Android’s blanket inversion and Outlook rendering the button through VML. There is no shortcut here, and that’s the point.

The rule is the spine of the whole lesson: the preview server is the inner loop, and the test-send is the gate the template passes before it’s wired into a Server Action and shipped. Iterate in the loop. Pass the gate. Then ship.

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

The preview-versus-gate distinction is the one 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.

Now see the whole thing as one chain. Each step below catches a class of failure the previous step cannot, and that structure is worth memorizing, because it tells you why no step is skippable. Scrub through it.

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, the loop is:

  1. Write the template in emails/welcome.tsx.
  2. Define realistic PreviewProps, since without them the preview has nothing to render.
  3. Run pnpm email dev in a side terminal.
  4. Save, then eyeball the render: desktop, toggle mobile, toggle 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, for cross-client confidence.
  7. Wire it into the Server Action: sendEmail({ react: <WelcomeEmail … /> }), the wrapper from the last chapter.

The skill this lesson is really teaching is this sequence: knowing the order, and knowing why each step is where it is. Reconstruct it from memory by dragging the steps into order.

Order the steps of the template iteration loop, from writing the template to shipping it. Drag the items into the correct order, then press Check.

Write the template in emails/welcome.tsx
Define realistic PreviewProps
Run pnpm email dev in a side terminal
Eyeball the render — desktop, then mobile, then dark
Read the plain-text view and confirm it’s coherent
Test-send to two real inboxes you actually open
Wire it into the Server Action with sendEmail

One operational issue to close out, because you’ll hit it the first time you run both servers at once.

Both pnpm dev (your Next.js app) and pnpm email dev (the preview server) default to port 3000. Start the second one and it will either refuse to boot or land somewhere unexpected. The fix is to move one of them, and the email server is the one to move, since your app’s URLs are baked into more config and it is the more expensive thing to relocate.

Terminal window
pnpm email dev --port 3001

The -p flag is the short form of --port, if you prefer it. The two servers are fully independent, since the preview server has nothing to do with your running Next.js app, so they coexist on different ports without issue.

The durable takeaways from this lesson:

  • pnpm email dev boots the preview server: a left-panel template list, a live render driven by PreviewProps, and a file watcher that hot-reloads on save. Keep it running in a second terminal.
  • The props panel turns PreviewProps into a live what-if surface. Stress-test long names and long URLs there, and keep PreviewProps shaped like the real payload or it teaches you nothing.
  • The mobile viewport toggle is required; the dark-mode toggle only approximates client inversion and is not authoritative.
  • The HTML view shows the pipeline’s inlined output, and the plain-text view shows the auto-derived text part, which the next lesson teaches exactly what to read for.
  • The preview server is the iteration loop, and the test-send is the verification gate: send to real inboxes you actually open before wiring the template into a Server Action.
  • Move one dev server off port 3000 with --port 3001, and document the choice in the README.

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