Emails
Typed email templates for Wander marketplace and WanderOS, built with React Email
pnpm add @wandercom/design-system-emailsRequires a React 19 or later runtime. React Email and its rendering dependencies are installed with the package.
The package exports composeEmail for rendering templates and EmailPreview
for displaying rendered HTML in an iframe:
import { composeEmail } from '@wandercom/design-system-emails';
const { html, text } = await composeEmail('os.transactional.guest.booking-confirmed', {
propertyName: 'Desert Modern Retreat',
propertyImageUrl: 'https://assets.wander.com/p/listing-hero.jpg',
bookingDates: 'Mar 15 – Mar 20, 2026',
bookingGuests: 2,
bookingUrl: 'https://example.com/bookings/abc123',
unitAddress: '1234 Desert View Dr, Joshua Tree, CA 92252',
mapsUrl: 'https://maps.google.com/?q=...',
checkInInstructions: 'Lockbox code is 1234. Check-in after 4pm.',
companyName: 'Desert Modern Retreat',
unsubscribeUrl: 'https://example.com/unsubscribe',
logoSrc: 'https://wander-ds.vercel.app/assets/wander-logomark.png',
logoDarkSrc: 'https://wander-ds.vercel.app/assets/wander-logomark-dark.png',
});The function is fully generic — TypeScript infers the correct props type from the template name, so you get autocomplete on both template keys and their required props.
EmailPreview accepts complete HTML. It does not fetch data or compose a
template, so render the email on the server before passing its HTML to the
component.
import {
composeEmail,
EmailPreview,
} from '@wandercom/design-system-emails';
export default async function WelcomeEmailPreview() {
const { html } = await composeEmail('os.marketing.guest.welcome', {
guestFirstName: 'Michael',
companyName: 'Desert Modern Retreat',
propertyCount: '12',
locationList: 'Joshua Tree, Sedona, and Big Bear',
browseUrl: 'https://example.com/properties',
hostName: 'Sarah',
unsubscribeUrl: '#',
logoSrc: 'https://wander-ds.vercel.app/assets/wander-logomark.png',
logoDarkSrc: 'https://wander-ds.vercel.app/assets/wander-logomark-dark.png',
});
return <EmailPreview html={html} title="Welcome email preview" />;
}The component assigns the HTML to the iframe's srcDoc attribute and applies a
restrictive sandbox by default. It does not send an email, execute scripts, or
persist the HTML.
When calling composeEmail from a Next.js application, externalize the server
renderer as one package. This lets Node resolve React Email and sanitizer
dependencies from the package that owns them:
const nextConfig = {
serverExternalPackages: ['@wandercom/design-system-emails'],
};
export default nextConfig;Do not add transitive dependencies such as PostCSS or Prettier to the application to suppress external-package warnings.
Live markup editors
Import the browser-safe preview entry when the HTML is controlled by a client
component. For frequently changing HTML, use BufferedEmailPreview. It keeps
the current content visible while the latest HTML loads in a hidden frame, then
replaces the visible frame when that load completes.
'use client';
import { BufferedEmailPreview } from '@wandercom/design-system-emails/preview';
import { useState } from 'react';
export function EmailMarkupEditor() {
const [html, setHtml] = useState('<h1>Welcome</h1>');
return (
<div>
<textarea
aria-label="Email HTML"
onChange={(event) => setHtml(event.currentTarget.value)}
value={html}
/>
<BufferedEmailPreview
html={html}
loadingFallback={<p>Loading preview...</p>}
title="Edited email preview"
/>
</div>
);
}The optional loadingFallback appears only while the first frame loads. After
that, the current preview remains visible until the latest hidden frame is
ready.
The sandbox blocks scripts and form submission by default, but embedded markup can still request remote assets such as images. Sanitize untrusted HTML before placing it in an editor or preview.
Templates use dot-notation keys: {domain}.{category}.{audience}.{template-name}
Guest
Operator
Services
Guest
Guest
Operator
genericforstringorstring[]bodiesgeneric.htmlfor sanitized HTML bodies
The generic template is a flexible template for ad-hoc emails that don't need a dedicated template:
const { html, text } = await composeEmail('generic', {
preview: 'Your settings have been updated',
heading: 'Your settings have been updated',
body: [
'We wanted to let you know that your account settings have been updated successfully.',
'If you did not make this change, please contact our support team immediately.',
],
buttonText: 'View settings',
buttonHref: 'https://wander.com/settings',
footer: 'wander',
logoSrc: 'https://wander-ds.vercel.app/assets/wander-logomark.png',
logoDarkSrc: 'https://wander-ds.vercel.app/assets/wander-logomark-dark.png',
companyName: 'Wander',
marketing: false,
unsubscribeUrl: 'https://example.com/unsubscribe',
});The body prop accepts a single string or an array of strings (each rendered as a separate paragraph). The button only renders when both buttonText and buttonHref are provided.
Use generic.html when the message body comes from trusted application or CMS HTML:
const { html, text } = await composeEmail('generic.html', {
preview: 'Your upcoming stay',
heading: 'Your upcoming stay',
bodyHtml: `
<p>Your stay begins on March 15.</p>
<img src="https://assets.wander.com/stay.jpg" alt="Desert Modern Retreat" />
<a data-email-button href="https://example.com/trips/abc123">View trip</a>
`,
buttonText: '',
buttonHref: '',
footer: 'wander',
logoSrc: 'https://wander-ds.vercel.app/assets/wander-logomark.png',
logoDarkSrc: 'https://wander-ds.vercel.app/assets/wander-logomark-dark.png',
companyName: 'Wander',
marketing: false,
unsubscribeUrl: 'https://example.com/unsubscribe',
});The HTML renderer supports headings, paragraphs, links, images, lists, and basic emphasis. It replaces source styles with email-safe inline styles. Add data-email-button to an anchor to use the primary button style.
Every template renders semantic HTML and a plain-text fallback. Provide meaningful property names and image descriptions because templates use those values for accessible image text. Keep heading order logical when using generic.html.
The generic.html template removes scripts, event handlers, unsafe URL schemes, unsupported elements, and source CSS. Sanitization does not make untrusted copy, destination URLs, or tracking images trustworthy. Only pass HTML from application-controlled or reviewed CMS sources.
Email clients apply HTML and CSS inconsistently. Dark mode is best effort, remote fonts may fall back to system fonts, and classic desktop Outlook may not render rounded image corners.
The composeEmail function returns { html, text } — the rendered HTML and plain text versions. Pass these to any email provider:
import { composeEmail } from '@wandercom/design-system-emails';
const { html, text } = await composeEmail('os.transactional.guest.booking-confirmed', {
// ... template props
});
await emailProvider.send({
from: 'Wander <notifications@wander.com>',
to: guest.email,
subject: 'Booking confirmed',
html,
text,
});Choose the preview workflow based on what you are changing. Use the embedded preview to verify a documented example in the design system site. Use the React Email development server when building or comparing templates directly.
The embedded docs example uses a server component to call composeEmail, then
passes the returned HTML to the package's EmailPreview component. The preview
loads that HTML directly through the iframe's srcDoc attribute; no rendering
API or client request is involved.
The docs preview does not select recipients, deliver mail, queue jobs, or persist preview data. The explicit props in the MDX example determine what appears in the iframe.
Before starting the docs site, build the design system so the docs app can load the current email package and generated registry:
pnpm build:design-system
pnpm dev:docsRun pnpm build:design-system again after changing an email template, shared
email component, package export, dependency, or registry input. Content-only
changes to this MDX file normally update through the docs development server
without another design system build.
To work on templates without the docs site, start the React Email development server from the email package:
cd packages/emails
pnpm devThe server starts on port 3000 and can use 3001 when port 3000 is already
in use. It discovers the template files under packages/emails/emails and
renders their default exports.
Some templates are thin wrappers over shared, copy-parameterized bases — for
example emails/shared/email-code.tsx and emails/shared/invite.tsx, plus
in-place bases such as payment-alert.tsx and w9-notice.tsx. Base files
export no default component, so the preview lists only the wrapper templates
that render them. Repeated layout blocks like the property hero and invoice
table live in packages/emails/components as PropertyCard and
InvoiceTable. When changing a shared base or block, check every template
that consumes it.
The React Email development server renders templates without application data. Values shown there come from defaults declared in the template component's parameter destructuring. A template without defaults for a required value may show incomplete or empty content in the local preview.
The package API is stricter. EmailTemplateMap makes every template prop
required at the composeEmail boundary, including props that have component
defaults. Embedded docs previews should also provide a complete props object so
the example matches production usage and remains type-safe.
Use safe, clearly fictional defaults. Do not place access tokens, private URLs, or customer information in template source or MDX preview props.
The embedded preview shows a loading indicator while the docs API renders the template. If the request fails, the iframe is replaced by an error message.
- A missing template value returns an HTTP
400response - An unknown template, incompatible props, or rendering exception returns an HTTP
500response - The docs server terminal contains the underlying render error
- Missing package, registry, or token modules usually mean
pnpm build:design-systemwas not run beforepnpm dev:docs - A stale local React Email preview may require restarting
pnpm devafter dependency or export changes - Remote images require a reachable HTTPS URL and may fail because of network, content security, or host restrictions
Preview tooling is for development and documentation, not for processing untrusted requests. The iframe sandbox prevents email HTML from running scripts in the docs page, but remote images and links can still contact external hosts. Use development-safe asset URLs and avoid sensitive query parameters.
generic.html sanitizes its body before rendering, as described in
HTML content and security. Sanitization is not a
replacement for trusted content sources, destination URL validation, access
control, or provider-side email policy checks.
The preview confirms rendered structure and styling in a browser. It does not replace testing in supported email clients, validating the plain-text result, or sending through the production email provider in a controlled environment.
The package exports these types for use in consuming applications:
import type {
EmailTemplateName,
EmailTemplateMap,
ComposeResult,
} from '@wandercom/design-system-emails';
import type {
BufferedEmailPreviewProps,
EmailPreviewProps,
} from '@wandercom/design-system-emails/preview';EmailTemplateName— union of all template key stringsEmailTemplateMap— maps each template key to its required propsComposeResult—{ html: string; text: string }EmailPreviewProps— props accepted byEmailPreviewBufferedEmailPreviewProps— props accepted byBufferedEmailPreview, including the optionalloadingFallback
- Shared utilities — Shared package utilities
- Design tokens — Token-based styling used in email components