Email Templates
Twelve production-ready, email-client-safe HTML templates (table layout, inline styles, Outlook-proof) plus a two-pane in-app browser at /email/ — preview each template in a sandboxed iframe, force-test its dark mode and mobile breakpoint, view the source, and copy or download the raw .html for your sending platform.
Overview
The module has two halves:
-
The templates themselves — 12 standalone HTML emails in
src/data/emailTemplates/, each a raw string export. The string is the product: it is a complete, self-contained document you copy into an ESP (Mailchimp, SendGrid, Postmark, Braze, …) and edit there. Every template follows the same engineering contract (documented in each module's header comment): table-based layout (role="presentation"), a 600px container centered in a full-widthbgcolorwrapper, an<!--[if mso]>ghost table for Outlook, all base styles inline (+bgcolorattributes), a bulletproof VMLv:roundrectCTA for Outlook, web-safe font stacks, a hidden preheader, andcolor-schememetas. The<style>block is progressive enhancement only — mobile stacking, aprefers-color-scheme: darkoverride block, hover states — so the email still renders correctly if a client strips it. -
The browser — one shared two-pane page (
EmailTemplatePage) mounted on all 12/email/*routes. The left pane is the template list; its rows areNavLinks, so selection is the route (no selection state). The right pane renders the raw template in a sandboxed<iframe srcDoc>— the email's own CSS never meets Tailwind or the app's preflight — with a toolbar for scheme/device testing, source view, copy, and download.
The 12 templates, grouped as in the list:
| Group | Templates (slug → route) |
|---|---|
| Account | welcome → /email/welcome · forgot-password → /email/forgot-password · activate → /email/activate |
| Billing | order-confirmation → /email/order-confirmation · payment-due → /email/payment-due |
| Marketing | product-drop → /email/product-drop · wrapped → /email/wrapped · winback → /email/winback · preferences → /email/preferences |
| Tables | large-table → /email/large-table · small-table → /email/small-table |
| Occasions | birthday → /email/birthday |
Bundle split by design. meta.ts holds descriptors only — no HTML — and is the side that route
registration reads; the raw HTML strings are aggregated by src/data/emailTemplates/index.ts, which is
imported only by EmailTemplatePage — so the full emails ship in that page's chunk, never in the
main bundle.
Architecture & files
| File | Responsibility |
|---|---|
src/pages/email/EmailTemplatePage.tsx | The shared two-pane browser (lazy, full-bleed). Resolves the template from its slug prop, renders the preview toolbar (scheme + device toggles, view source, download, copy) and the sandboxed <iframe srcDoc>, and owns the forceScheme preview rewrite. |
src/components/email/EmailTemplateList.tsx | Left pane: the templates grouped into sections, each row an accent-tinted icon tile + name + subject line. Rows are NavLinks to their /email/* paths (selection = route); the active row swaps to the solid accent tile. Staggered entrance via the motion tokens. |
src/components/email/EmailSourceModal.tsx | Raw-HTML viewer: an editor-style CodeBlock (<slug>.html tab, line-wrap toggle) inside a size="xl" Modal, with KB/line-count stats and Copy / Download footer actions. |
src/data/emailTemplates/meta.ts | The eager descriptor side: EmailTemplateSlug, EmailTemplateMeta, EMAIL_TEMPLATE_GROUPS, and EMAIL_TEMPLATES (slug, path, nav:/email: keys, icon, accent, group). Read by route registration — deliberately contains no template HTML. |
src/data/emailTemplates/index.ts | The heavy side: EMAIL_TEMPLATE_HTML, a Record<EmailTemplateSlug, string> aggregating the 12 raw templates. Imported only by the lazy page. |
src/data/emailTemplates/welcome.ts (+ 11 siblings) | One template per file — a single exported HTML string (e.g. WELCOME_EMAIL) with the shared engineering contract documented in the header comment. |
src/locales/en/email.json | The email namespace: list hint, group headings, toolbar/modal chrome, per-template descriptions (*Desc), and the displayed subject lines (subject*). |
The template modules are: welcome.ts, orderConfirmation.ts, paymentDue.ts,
forgotPassword.ts, activateAccount.ts, birthdayWish.ts, largeTable.ts, smallTable.ts,
productDrop.ts, wrapped.ts, winback.ts, preferences.ts.
Routing
All 12 routes come from the metadata, so EMAIL_TEMPLATES is the only place the list is maintained.
One page.tsx per template is generated from that array, each passing its own slug to the shared page:
// src/app/[locale]/(app)/email/welcome/page.tsx — generated
export default async function Page({params}: {params: Promise<{locale: string}>}) {
const {locale} = await params
setRequestLocale(locale)
return <EmailTemplatePage slug="welcome" />
}So adding a descriptor to EMAIL_TEMPLATES and re-running npm run codegen is what creates the route —
you never hand-write one of these files.
The matching sidebar leaves live under Pages → Email Templates in src/data/menu.ts (12 leaves,
paths matching EMAIL_TEMPLATES exactly), with labels in the nav namespace (emailWelcome …
emailPreferences).
Preview rendering
The preview is a sandboxed iframe, keyed by slug so switching templates remounts it cleanly:
<iframe key={template.slug} title={name} srcDoc={previewHtml} sandbox="" … />sandbox=""(no tokens) is safe because the emails contain no scripts.- The canvas width follows the device toggle —
PREVIEW_WIDTH = {desktop: 640, mobile: 375}(640 shows the 600px email plus its own gutter; 375 triggers the template'smax-width: 620pxstacking breakpoint). - The app's dark theme does not restyle the preview. Each template carries exactly one
prefers-color-scheme: darkblock, and the toolbar's Sun/Moon toggle force-tests it viaforceScheme, which rewrites that media query to@media all(always matches) or@media not all(never matches). This is preview-only — the copied/downloaded HTML keeps the original OS-driven media query.
Copy / download
- Copy HTML writes the untouched template string to the clipboard
(
navigator.clipboard.writeText) and fires a success toast. - Download builds a
Blobof typetext/html;charset=utf-8and saves it as<slug>.html. - View source opens
EmailSourceModal— the same string in aCodeBlockwith size stats (KB · lines) and the same Copy/Download actions.
Usage
Navigate to any leaf under Pages → Email Templates in the sidebar (e.g. /email/welcome).
Because the routes are generated from EMAIL_TEMPLATES, no per-template wiring exists — the page
resolves everything from its slug:
import {EmailTemplatePage} from '@/pages/email/EmailTemplatePage'
;<EmailTemplatePage slug="order-confirmation" />To use a template's HTML directly in code (a mail-send integration, a preview elsewhere):
import {EMAIL_TEMPLATE_HTML} from '@/data/emailTemplates'
const html = EMAIL_TEMPLATE_HTML['forgot-password']Info
Import EMAIL_TEMPLATE_HTML only from code that is itself code-split. Anything eagerly loaded should
import from @/data/emailTemplates/meta (descriptors only) to keep the raw emails out of the
main bundle.
To ship a template to your ESP: open it, click Copy HTML (or Download .html), and paste the markup into your platform's custom-HTML editor. The artifact is fully standalone — no external CSS, no scripts, no app dependencies.
API / Props
EmailTemplateMeta (src/data/emailTemplates/meta.ts)
| Field | Type | Description |
|---|---|---|
slug | EmailTemplateSlug | Stable id; also names the downloaded file (<slug>.html) and keys EMAIL_TEMPLATE_HTML. |
path | string | Absolute route path — must match the menu.ts Email Templates leaf exactly. |
navKey | string | nav: label key (the template's display name). |
subjectKey | string | email: key for the subject line shown in the list row (the HTML artifact keeps its EN subject). |
descKey | string | email: description key for the preview toolbar. |
icon | LucideIcon | List-row / mobile-switcher icon. |
accent | Accent | Icon-tile tone, resolved through accentTile / accentTileSolid in src/lib/accent.ts. |
groupKey | (typeof EMAIL_TEMPLATE_GROUPS)[number] | email: heading key of the list section (groupAccount / groupBilling / groupMarketing / groupTables / groupOccasions). |
EmailTemplatePageProps (EmailTemplatePage.tsx)
| Prop | Type | Description |
|---|---|---|
slug | EmailTemplateSlug | Which template to show (set per route). Unknown slugs fall back to the first template. |
EmailSourceModalProps (EmailSourceModal.tsx)
| Prop | Type | Description |
|---|---|---|
open | boolean | Modal visibility. |
onClose | () => void | Close handler. |
name | string | Template display name (already translated). |
slug | string | Names the editor tab (<slug>.html). |
html | string | The raw template source. |
onDownload | () => void | Saves the template as a .html file (owned by the page, shared with its toolbar). |
Data exports
| Export | From | Description |
|---|---|---|
EMAIL_TEMPLATES | @/data/emailTemplates/meta | The 12 descriptors, in list order (eager-safe). |
EMAIL_TEMPLATE_GROUPS | @/data/emailTemplates/meta | Section order for the list (5 email: heading keys). |
EMAIL_TEMPLATE_HTML | @/data/emailTemplates | Record<EmailTemplateSlug, string> of raw HTML (lazy side only). |
EmailTemplateList takes no props — it reads EMAIL_TEMPLATES directly and navigates via
NavLink.
Configuration & customization
Add a new template
Four spots, mirroring the add-a-page recipe:
- HTML module — create
src/data/emailTemplates/myTemplate.tsexporting one HTML string. Follow the shared contract (copy an existing module as the starting point): 600px table layout, inline base styles,<!--[if mso]>ghost table + VML CTA, oneprefers-color-scheme: darkblock (the preview's scheme toggle depends on exactly one such block), hidden preheader. Register it insrc/data/emailTemplates/index.ts(EMAIL_TEMPLATE_HTML). - Descriptor — add the slug to the
EmailTemplateSlugunion and an entry toEMAIL_TEMPLATESinmeta.ts(path,navKey,subjectKey,descKey, icon, accent, group). The route, list row, and mobile switcher all derive from this entry — no page or route code to touch. - Menu leaf — add the matching item under Pages → Email Templates in
src/data/menu.ts. - i18n — add the
nav:<navKey>label plusemail:<subjectKey>andemail:<descKey>to the locale files.
Add a list section
Append a key to EMAIL_TEMPLATE_GROUPS in meta.ts and add the email: heading to the locale
files; empty groups are skipped automatically.
Rebrand the emails
The templates deliberately hardcode hex colors — email clients don't support CSS variables —
sampled from the app's default light/dark tokens (e.g. primary #0067ff, canvas #f6f7f9, dark
canvas #0b1120; the exact palette is documented in each module's header comment). To rebrand,
find-and-replace those hex values inside the template strings; the app's skins and dark mode never
touch them. The wordmark is a text masthead (asset-free), so no logo image needs hosting.
Preview widths
PREVIEW_WIDTH in EmailTemplatePage.tsx maps the device toggle to canvas widths
(desktop: 640, mobile: 375). Adjust or extend it if your templates use a different container
width — keep mobile below each template's stacking breakpoint (620px in the shipped set).
Examples
Send a template through your own backend
import {EMAIL_TEMPLATE_HTML} from '@/data/emailTemplates'
import {api} from '@/lib/api'
await api.post('/notifications/email', {
to: 'customer@example.com',
subject: 'Welcome aboard',
html: EMAIL_TEMPLATE_HTML.welcome,
})Render a mini preview elsewhere in the app
import {EMAIL_TEMPLATE_HTML} from '@/data/emailTemplates'
;<iframe title="Receipt preview" srcDoc={EMAIL_TEMPLATE_HTML['small-table']} sandbox="" className="h-96 w-full rounded-xl border border-border" />Build a template picker from the metadata
import {EMAIL_TEMPLATES} from '@/data/emailTemplates/meta'
import {useTranslation} from '@/platform/i18n'
function TemplatePicker({onPick}: {onPick: (slug: string) => void}) {
const {t} = useTranslation('nav')
return EMAIL_TEMPLATES.map((tpl) => (
<button key={tpl.slug} onClick={() => onPick(tpl.slug)}>
<tpl.icon /> {t(tpl.navKey)}
</button>
))
}Best practices
- Keep the eager/lazy split. Never import
@/data/emailTemplates(the HTML aggregate) from eager code — descriptors come from@/data/emailTemplates/meta. Breaking this ships every email in the main bundle. - Treat the string as the artifact. Edit copy and colors inside the template module; don't try to theme it with app tokens or Tailwind classes — email clients see none of that.
- Preserve the contract when editing. Keep base styles inline (the
<style>block must remain optional), keep the MSO conditional wrappers, and keep exactly oneprefers-color-scheme: darkblock so the preview's scheme toggle keeps working. - Test both toggles before shipping copy changes. The mobile width exercises the stacking breakpoint; the Moon toggle exercises the dark overrides — both are one click in the toolbar.
- Store keys for chrome, literals for the artifact. Anything rendered by the app (names, subjects in the list, descriptions, toolbar labels) is an i18n key; anything inside the HTML string stays literal English on purpose.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Preview ignores the app's dark theme | By design — the iframe is isolated, and the email's own prefers-color-scheme block decides. Use the toolbar's Sun/Moon toggle to force either scheme. |
| Scheme toggle does nothing on a custom template | forceScheme rewrites the @media (prefers-color-scheme: dark) string. Your template must contain exactly that query (once) for the toggle to find it. |
| Copied HTML renders light-only / dark-only | It doesn't — the force-scheme rewrite is preview-only. Copy/Download always emit the original string with the OS-driven media query intact. |
| Template looks unstyled in an email client | The client stripped the <style> block, which is expected — but base styles must be inline. If you added styles only to the <style> block, duplicate them inline. |
| New template's route 404s / renders a placeholder | The route is generated from EMAIL_TEMPLATES — check the meta.ts entry exists and its path matches the menu.ts leaf exactly. |
| Raw emails appear in the main JS bundle | Something eager imports @/data/emailTemplates (the index.ts aggregate). Only the lazy EmailTemplatePage may import it; eager code uses meta.ts. |
| Copy button silently does nothing | navigator.clipboard is unavailable (insecure context). Use Download instead, or serve over HTTPS/localhost. |
FAQ
Are the templates translated? No — deliberately. The HTML is a shippable artifact buyers copy
out and edit, and running markup-laden strings through the flat-JSON translate pipeline would
mangle them. Only the browser chrome (including the subject line displayed in the list, via
subjectKey) is translated through the email namespace.
Why an iframe instead of dangerouslySetInnerHTML? Isolation. The email carries its own
<style> block, resets, and document structure; srcDoc in a sandboxed iframe keeps that CSS from
colliding with Tailwind's preflight (and vice versa), and renders the email exactly as a standalone
document — which is what a client will see.
Do the templates work in Outlook? Yes — that's what the <!--[if mso]> ghost tables, the VML
v:roundrect CTA, and mso-line-height-rule: exactly are for.
Is there any persistence? No. The browser is stateless (device/scheme toggles reset per visit),
and selection is just the route — nothing is written to localStorage.
Can I use these with a templating engine? Yes — the strings are plain HTML; add your engine's
placeholders ({{first_name}}, *|ORDER_ID|*, …) after copying into your ESP.
Notes for designers & content editors
- Template copy, subjects, and colors inside the emails live in the
src/data/emailTemplates/*.tsmodules as literal HTML — edit them there. They are intentionally independent of the app's tokens, skins, and dark mode. - Browser chrome (list hint, group headings, displayed subjects, descriptions, toolbar labels,
toasts) lives in
src/locales/<lng>/email.json; template display names live in thenavnamespace. Never edit these in the components. - List accents are semantic (
primary/success/warning/danger/infoviasrc/lib/accent.ts), so the list re-skins with the app even though the emails don't. - Preheaders matter: each template hides a preheader
<div>right after<body>— it's the inbox preview line, so keep it updated when the headline changes. - The emails are asset-free (text wordmark, no hosted images), so previews and copies never break on missing resources.
Related
Architecture & Routing
how EMAILTEMPLATES folds into route registration, code splitting, and full-bleed presentation
Layout System
full-bleed sizing (useFixedFooterHeight), the shell the browser sits in
Sidebar & Navigation
the Pages → Email Templates menu group
Overlays & Disclosure
the Modal and Dropdown primitives used by the source viewer and mobile switcher
Core & Feedback
Button, Badge, Tooltip, Toast
i18n
the email namespace and the keys-not-copy convention
Was this page helpful?
