@chakra-email/preview
v0.2.0
Published
Local React email preview server with live rendering, template variants, and email linting.
Maintainers
Readme
@chakra-email/preview
Local React email preview server with live rendering, template variants, and email linting.
Created by Ryan Hefner and Commune Software.
@chakra-email/preview discovers template modules in your repository, renders
their default exports, and lets you exercise representative props without
adding preview code to your production application. It provides the
chakra-email-preview CLI and a programmatic server API.
Install
Install the preview package as a development dependency in the repository that contains your emails:
npm install --save-dev @chakra-email/preview[!NOTE]
@chakra-email/previewis ESM-only. Node.js 20.19.0 is the compatibility floor; Node.js 22 or 24 is recommended for new development environments.
Your email library should also install its normal rendering package and React peer dependencies, for example:
npm install chakra-email react react-domConfigure
Create chakra-email.config.ts next to the email project or package:
import { defineConfig, previewSlotRecipeKeys } from '@chakra-email/preview';
export default defineConfig({
root: '.',
templates: './src/emails',
include: ['**/*.email.{ts,tsx}'],
exclude: ['**/*.{test,spec}.{ts,tsx}', '**/__fixtures__/**'],
assets: './public',
host: '127.0.0.1',
allowedHosts: ['chakra-email.test'],
port: 4100,
theme: {
semanticTokens: {
colors: {
preview: {
accent: { value: { _light: '#171717', _dark: '#fafafa' } },
accentInk: { value: { _light: '#ffffff', _dark: '#171717' } },
},
},
},
slotRecipes: {
[previewSlotRecipeKeys.workspace]: {
base: {
header: { bg: 'preview.panel' },
sidebar: { bg: 'preview.canvas' },
},
},
},
},
});The default renderer is chakraEmailRenderer from @chakra-email/core. To
preview templates that rely on React Email rendering behavior, install the
optional adapter and configure it server-side:
import { reactEmailRenderer } from '@chakra-email/react-email';
import { defineConfig } from '@chakra-email/preview';
export default defineConfig({
renderer: reactEmailRenderer(),
});root is resolved relative to the directory containing the configuration
file. templates and assets are resolved relative to root; include and
exclude patterns are evaluated inside each templates directory. When neither
templates nor include is configured, templates are discovered under
./emails.
rootsets the base directory for discovery and static assets.templatesselects the template directory.includeadds the template-module glob patterns to discover.excluderemoves matching modules from discovery.assetsselects the static asset directory used by the preview server.hostdefaults to the loopback-only address127.0.0.1.allowedHostsaccepts exact reverse-proxy hostnames such aschakra-email.test; wildcards and ports are rejected.portdefaults to4100.themeaccepts a JSON-serializable Chakra theme fragment for the preview UI, includingtokens,semanticTokens,recipes, andslotRecipes.rendereraccepts anEmailRendererimplementation and remains on the preview server; functions are never serialized into the browser UI.testSendaccepts a server-only delivery adapter. When configured, the inspector exposes recipient and subject fields for sending the active render.
The theme is serialized by the local preview server and merged over the
preview UI defaults in the browser. Keep it data-only: functions and a
pre-created Chakra SystemContext cannot cross that boundary.
The neutral default system uses preview.* semantic color tokens and these
exported slot recipe keys:
workspace: the application shell, header, sidebar, toolbar, and content.templates: template list, item, avatar, text, and selected-state slots.viewer: format tabs, preview surface, email frame, and source viewer.inspector: props editor, controls, notice, and lint result slots.feedback: error banner, tooltip, and sharediconButtonsizing slots.
Each recipe supports partial overrides, so omitted defaults remain intact. Use
the exported previewSlotRecipeKeys values as computed keys to keep theme
configuration resilient to future package changes.
A missing assets directory does not prevent the server from starting; requests
for files that are not present return 404.
Preview workspace
The workspace uses a compact header and format toolbar, a flat template list, and a centered email canvas. The workspace and email frame fill the available viewport height; long emails scroll inside the frame rather than stretching the page. The inspector scrolls independently.
- Header panel buttons show or hide templates and preview settings. Hiding settings preserves unapplied JSON edits, variants, and test-send fields. Escape from either panel closes it and returns focus to its toggle.
- Small screens start with both panels closed. Opening a panel uses the available workspace; selecting a template returns to the email canvas.
- Desktop (680px), Mobile (390px), and Fit presets change the preview width. Drag the frame's bottom-right corner to resize it. Reset size restores the selected preset and default height. The footer describes the preset, not the dimensions of a manually resized frame.
- Copy, Download, viewport presets, and Reset size use icon buttons with accessible names and tooltips on hover or keyboard focus. Copy and Download describe the active output format; copying shows a checkmark and announces success. Remote images and form actions retain visible labels. The header's sun/moon icon button toggles the email preview between Light and Dark, with an accessible name and tooltip describing the next mode.
- Preview, HTML, Text, and Source form a compact icon tab group with tooltips
and accessible names. They retain tab/panel associations, a single tab stop,
and arrow-key / Home / End navigation. The viewer
tabsandtabslots customize the group and selected styles; shared icon sizing applies here too. - The
feedback.iconButtonslot controls the compact actions' shared geometry. Set--preview-icon-button-sizeto customize the hit area (default: 44px on small screens, 32px frommdupwards). Existing action recipes still control colors, borders, and interaction styling. - The workspace and canvas always use dark mode, including built-in components,
portalled tooltips, and custom semantic tokens. The header's email color-mode
toggle affects only the email frame. Customize the canvas background in the
viewer recipe's
surfaceslot (default:preview.soft); recipe and theme overrides remain supported. Light and Dark force Chakra Email's generated color rules in the preview frame. Explicit email choices persist locally; a new session without a saved choice defaults to Light. The former footer System/Light/Dark selector is removed, saved System choices fall back to Light, and old workspace-mode preferences are ignored. OS color-mode changes do not change either surface. Arbitrary authored media queries are not rewritten, and client-specific automatic color inversion is not simulated. Fixed template colors remain fixed. Scripts and forms stay disabled, and remote images still require explicit opt-in.
These controls affect only the preview, not generated HTML, plaintext, or template source. Full template paths remain available through Chakra tooltips on the template rows, including on keyboard focus. Controls use Chakra UI v3 primitives, including Tabs, Switch, Field, NativeSelect, and portalled Tooltip.
Tooltips inherit Chakra's default surface, arrow, focus, and dismissal behavior.
To customize both the surface and arrow together, set --tooltip-bg in the
feedback recipe's tooltip slot (and color for the text), rather than
overriding bg alone:
theme: {
slotRecipes: {
[previewSlotRecipeKeys.feedback]: {
base: {
tooltip: { '--tooltip-bg': 'colors.gray.800', color: 'white' },
},
},
},
}The existing recipe keys remain supported. The workspace recipe also exposes
an inspectorPanel slot and templatesOpen / inspectorOpen variants. The
viewer recipe exposes a stage slot for the centered area surrounding the
frame. For example:
theme: {
slotRecipes: {
[previewSlotRecipeKeys.viewer]: {
base: {
stage: { p: { base: '2', md: '6' } },
frame: { borderRadius: '0', boxShadow: 'none' },
},
},
[previewSlotRecipeKeys.workspace]: {
variants: {
inspectorOpen: {
true: {
content: {
gridTemplateColumns: {
base: 'minmax(0, 1fr)',
md: 'minmax(0, 1fr) 320px',
},
},
},
},
},
},
},
}Template Modules
A template module must default-export the React email component:
import { Body, Html, Text } from 'chakra-email';
export type WelcomeEmailProps = {
firstName: string;
plan: 'free' | 'pro';
};
export const previewProps: WelcomeEmailProps = {
firstName: 'Ada',
plan: 'free',
};
export const previewVariants = {
'Pro account': {
plan: 'pro',
},
'Long name': {
firstName: 'Alexandria Catherine',
},
} satisfies Record<string, Partial<WelcomeEmailProps>>;
export const previewSubject = (props: WelcomeEmailProps) =>
`Welcome ${props.firstName}`;
export default function WelcomeEmail({ firstName, plan }: WelcomeEmailProps) {
return (
<Html>
<Body>
<Text>
Welcome, {firstName}. Your {plan} account is ready.
</Text>
</Body>
</Html>
);
}previewProps supplies the base props. Each selected previewVariants entry
is merged over that base, and props entered in the preview JSON editor are
merged last. Variant values can therefore contain only the fields that differ
from the base case.
previewSubject may be a string or a synchronous/async function. Functions
receive those fully merged props. The subject appears in the workspace,
prefills test sending, and is recorded in the export manifest.
For compatibility with templates that follow the React Email convention, a
component-level PreviewProps value is used when the module has no named
previewProps export:
function ReceiptEmail({ orderId }: { orderId: string }) {
return <div>Receipt for {orderId}</div>;
}
ReceiptEmail.PreviewProps = {
orderId: 'order_123',
};
export default ReceiptEmail;When both forms exist, the named previewProps export takes precedence over
Component.PreviewProps.
Run The Preview
Run the installed binary from the email project:
chakra-email-preview --config chakra-email.config.tsAn npm script keeps that command consistent for the team:
{
"scripts": {
"email:dev": "chakra-email-preview --config chakra-email.config.ts"
}
}npm run email:devThe CLI accepts:
--config <path>,-c <path>to select a configuration file.--host <host>to override the configured host.--port <port>,-p <port>to override the configured port.--allow-remoteto acknowledge a non-loopback bind explicitly.--help,-hto print command help.--version,-vto print the package version.
Host and port flags take precedence over values in the configuration file.
For Springbar, Caddy, or another local reverse proxy, keep the bind address on
loopback and add the routed hostname to allowedHosts. This changes only exact
Host-header validation; it does not enable wildcards or remote binding.
The workspace toolbar can download the active HTML, plain-text, or source output. For repeatable build artifacts, export every template from the CLI:
chakra-email-preview export \
--config chakra-email.config.ts \
--out-dir dist/emailsExport includes the default props and every named preview variant. Use
--default-only to omit variants, --format html|text|both to select output,
or --compact to skip HTML pretty-printing. Output paths mirror template paths
and variant filenames include a deterministic suffix to prevent collisions.
The exporter also writes manifest.json with template IDs, source paths,
variants, derived subjects, and relative artifact paths. Programmatic callers
can set manifest: false when another build system owns the manifest.
Test-send adapters
Test sending is opt-in and provider-neutral. Supply an adapter in the local config; it receives the same HTML, text, props, and variant currently rendered by the preview:
import { Resend } from 'resend';
import { defineConfig } from '@chakra-email/preview';
const resend = new Resend(process.env.RESEND_API_KEY);
export default defineConfig({
testSend: {
async send(message) {
const { data, error } = await resend.emails.send({
from: 'Preview <[email protected]>',
to: message.to,
subject: message.subject,
html: message.html,
text: message.text,
});
if (error) throw new Error(error.message);
return { id: data?.id };
},
},
});Provider SDKs and credentials stay in the consuming project. The adapter runs only in the preview server and is not serialized into the browser UI. The preview package does not send anything unless a developer submits the local test-send form.
For Mailpit, keep Nodemailer in the consuming project rather than preview core:
import nodemailer from 'nodemailer';
import { defineConfig } from '@chakra-email/preview';
const smtp = nodemailer.createTransport({
host: '127.0.0.1',
port: 1025,
secure: false,
});
const allowedRecipients = new Set(
(process.env.EMAIL_PREVIEW_RECIPIENTS ?? '')
.split(',')
.map((value) => value.trim().toLowerCase())
.filter(Boolean),
);
export default defineConfig({
allowedHosts: ['emails.example.test'],
testSend: {
async send(message) {
if (!allowedRecipients.has(message.to.toLowerCase())) {
throw new Error('Recipient is not allowed for local preview.');
}
const result = await smtp.sendMail({
from: '[email protected]',
to: message.to,
subject: message.subject,
html: message.html,
text: message.text,
});
return { id: result.messageId };
},
},
});An allowed proxy host is not authentication. Keep test sending on a trusted development network, use a recipient allowlist, and never expose a provider-backed send endpoint through a public preview route.
Nx Libraries
Attach a continuous, non-cacheable target to the Nx library that owns the
emails. cwd is workspace-relative when using nx:run-commands:
{
"name": "@acme/emails",
"nx": {
"targets": {
"email-preview": {
"executor": "nx:run-commands",
"continuous": true,
"cache": false,
"options": {
"cwd": "packages/emails",
"command": "chakra-email-preview --config chakra-email.config.ts"
}
}
}
}
}Run it through the workspace package manager:
npm exec nx -- run @acme/emails:email-previewThe target is continuous because the server remains active and watches local templates. It is not cacheable because it produces no reusable build artifact.
Programmatic API
Use createPreviewServer when another development tool needs to own the server
lifecycle:
import { createPreviewServer } from '@chakra-email/preview';
const server = await createPreviewServer({
configFile: './packages/emails/chakra-email.config.ts',
cwd: process.cwd(),
host: '127.0.0.1',
port: 4100,
});
await server.listen();
process.once('SIGINT', async () => {
await server.close();
});The function accepts either configFile or an inline config, plus optional
cwd, host, port, and allowRemote overrides. The returned server exposes
listen() and close().
Use the same one-shot exporter from build scripts:
import { exportTemplates } from '@chakra-email/preview';
const result = await exportTemplates({
configFile: './chakra-email.config.ts',
outDir: './dist/emails',
format: 'both',
});Email Lint Checks
Each rendered template includes structured lint findings, and the browser shows them in the Client checks panel. Findings refresh with template, variant, and preview-prop changes and include severity, a stable rule ID, suggested remediation, and rendered-HTML location where available. Relevant compatibility findings link to their live Can I Email feature page; the package does not copy support percentages that can become stale.
The checks cover malformed or incomplete documents, missing metadata and alternative text, insecure URLs, unsupported interactive or embedded elements, risky CSS layout, image dimensions, plain-text output, and message size.
The same rule set is available without starting a server:
import { lintRenderedEmail } from '@chakra-email/preview';
const findings = lintRenderedEmail(renderedHtml, renderedPlainText);These checks surface common risks; they do not replace delivery-provider tests or validation in the mailbox clients your application supports.
Optional Network Link Checks
Static linting never fetches email URLs. Enable the separate Check links
action with an exact hostname allowlist in chakra-email.config.ts:
import { defineConfig } from '@chakra-email/preview';
export default defineConfig({
linkCheck: {
allowedHosts: ['allplay.fm'],
excludedUrls: ['https://allplay.fm/sensitive-action'],
timeoutMs: 3000,
maxUrls: 30,
cacheTtlMs: 60000,
},
});Click Check links in the props/inspector panel to re-render the active template with its applied props and check its anchor URLs. Unsaved editor changes are not applied. Findings include rendered-HTML line numbers:
- HTTP 404/410: broken-link errors (verify manually; HEAD can differ from GET).
- HTTP 3xx: informational redirects with the destination displayed, never followed.
- Other unsuccessful responses, DNS/TLS failures, and timeouts: unverified warnings.
- Excluded or unsupported links: skipped notices, not successful checks.
Checks run only on demand, not on render, hot reload, export, or test sending.
New renders clear network findings. Duplicate URLs share requests; results are
cached for the configured TTL (including failures). Set cacheTtlMs: 0 to
disable caching. A maximum of three requests run concurrently, one check runs
per server, and the cache contains at most 500 entries. Input HTML is limited
to 2 MiB. maxUrls accepts 1–100, timeoutMs 100–10000, and cacheTtlMs
0–300000. The checked count includes cached results; skipped links and redirect
destinations remain unverified.
For programmatic validation outside the preview UI:
import { createEmailLinkChecker } from '@chakra-email/preview';
const checkLinks = createEmailLinkChecker({ allowedHosts: ['allplay.fm'] });
const { findings, checked, skipped } = await checkLinks(renderedHtml);Network Safety
Requests use HEAD only, without cookies, authorization, automatic redirects, or GET fallback. Only standard HTTP(S) ports and public IPv4 destinations are supported; IPv6-only hosts are reported as unverified. DNS answers are checked for private/reserved addresses and the approved address is pinned to the connection to avoid DNS rebinding. The API retains preview-token, Host, same-origin, JSON-body, and registered-template checks.
Query-bearing URLs and common unsubscribe, opt-out, login, magic-link,
verification, reset, and tracking paths are always skipped. Credentials,
relative URLs, fragments, non-HTTP protocols, and non-allowlisted hosts are not
requested. excludedUrls adds exact URL exclusions (ignoring fragments).
Redirect query strings are redacted from findings.
Even HEAD requests can trigger side effects on poorly behaved endpoints, and
heuristics cannot recognize every sensitive route. Use synthetic preview data,
allow only trusted hosts, exclude application-specific action URLs, and do not
expose the preview server to untrusted users. An unsubscribe placeholder such
as https://example.com/unsubscribe is intentionally skipped; validate real
unsubscribe flows separately with test recipients.
Security And Image Privacy
The preview server is a local development tool. It binds to 127.0.0.1 by
default and refuses an intentional remote bind unless --allow-remote (or the
programmatic allowRemote option) is supplied. Do not expose it to a public or
untrusted network: discovered template modules execute locally with the same
permissions as the CLI process. Its random per-process request token is a local
request guard, not multi-user authentication; remote binding does not add
access control or TLS.
Remote images can disclose the developer's IP address, user agent, and request
timing to the image host. The preview iframe blocks remote image requests by
default; developers can opt in with the visible Remote images control. The
MVP does not proxy, anonymize, or cache requests after that opt-in. Prefer local
fixtures or image hosts you trust while developing.
Preview-local assets are not production email URLs; use deliberate absolute
public or cid: sources before sending a message.
Scope limits
The preview does not currently provide:
- a drag-and-drop or visual template editor;
- mailbox-client emulation, screenshots, or compatibility certification;
- remote-image proxying or anonymization;
- an Nx plugin, generator, or inferred target;
- a production hosting or multi-user preview service;
- isolation for untrusted template code.
Use actual delivery-provider and mailbox-client testing before treating an email as production-ready.
More Docs
Help and contributing
See the project README, open an issue, or read the contribution guidelines. Report vulnerabilities privately through the security policy.
License
MIT
