Most email templates are static. You build them once, and every user sees the same layout, same blocks, same everything.
The problem? Real-world emails need to adapt: show different sections based on user data, render variable-length lists, handle missing data gracefully, and personalize without creating template chaos.
5 Dynamic Content Patterns That Scale
These patterns solve the most common React Email scenarios. Use them as building blocks, not rigid rules.
1) Conditional Blocks — Show/Hide Entire Sections
The simplest pattern: render a section only when the data exists.
export default function OrderConfirmation({ order }: Props) {
return (
<Email>
<Header />
<Section>
<Heading>Order #{order.id}</Heading>
<Text>Thanks for your purchase!</Text>
</Section>
{/* Conditional: only show if discount applied */}
{order.discountAmount && (
<Section style={{ backgroundColor: "#f0fdf4" }}>
<Text>
You saved <Strong>${order.discountAmount}</Strong> with code{" "}
<InlineCode>{order.discountCode}</InlineCode>
</Text>
</Section>
)}
{/* Conditional: shipping info for physical products */}
{order.shippingAddress && (
<Section>
<Heading as="h2">Shipping to:</Heading>
<Text>{order.shippingAddress.name}</Text>
<Text>{order.shippingAddress.street}</Text>
<Text>
{order.shippingAddress.city}, {order.shippingAddress.zip}
</Text>
</Section>
)}
<Footer />
</Email>
);
}2) Dynamic Lists — Render Variable-Length Data
When you need to render an unknown number of items — order line items, activity logs, recommended products.
export default function Receipt({ items, total }: Props) {
return (
<Email>
<Section>
<Heading>Your receipt</Heading>
{/* Dynamic list: map over items */}
{items.map((item) => (
<Row key={item.id}>
<Column style={{ width: "60%" }}>
<Text>{item.name}</Text>
{item.variant && (
<Text style={{ color: "#666", fontSize: "14px" }}>
{item.variant}
</Text>
)}
</Column>
<Column style={{ width: "20%", textAlign: "center" }}>
<Text>×{item.quantity}</Text>
</Column>
<Column style={{ width: "20%", textAlign: "right" }}>
<Text>${item.price}</Text>
</Column>
</Row>
))}
{/* Totals section */}
<Hr style={{ margin: "24px 0" }} />
<Row>
<Column style={{ textAlign: "right" }}>
<Strong>Total: ${total}</Strong>
</Column>
</Row>
</Section>
</Email>
);
}Edge case handling: what if the list is empty?
export default function ActivityDigest({ activities }: Props) {
return (
<Email>
<Section>
<Heading>Your weekly activity</Heading>
{activities.length > 0 ? (
activities.map((activity) => (
<ActivityCard key={activity.id} {...activity} />
))
) : (
<Text style={{ color: "#666", fontStyle: "italic" }}>
No activity this week. Start a new project to get going!
</Text>
)}
</Section>
</Email>
);
}3) Fallback Chains — Handle Missing Data Gracefully
Production data is messy. Users might not have a profile photo, display name, or phone number. Use fallback chains to avoid rendering undefined or empty strings.
export default function TeamInvite({ inviter, teamName }: Props) {
// Fallback chain: display name → email → "A teammate"
const inviterName =
inviter.displayName || inviter.email || "A teammate";
// Avatar fallback: use initials if no photo
const avatarUrl = inviter.photoUrl || null;
const initials = inviter.displayName
? inviter.displayName
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
: "?";
return (
<Email>
<Section style={{ textAlign: "center" }}>
{avatarUrl ? (
<Img
src={avatarUrl}
alt={inviterName}
width={64}
height={64}
style={{ borderRadius: "50%" }}
/>
) : (
<div
style={{
width: 64,
height: 64,
borderRadius: "50%",
backgroundColor: "#3b82f6",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "24px",
fontWeight: 600,
}}
>
{initials}
</div>
)}
<Heading>{inviterName} invited you to {teamName}</Heading>
<Text>Accept the invitation to start collaborating.</Text>
<Button href={acceptUrl}>Join {teamName}</Button>
</Section>
</Email>
);
}getUserDisplayName(user) or formatPhoneNumber(user.phone) helpers.4) Variant Switching — Different Content for Different States
Sometimes you need entirely different messaging based on a status, user type, or action. Don't create separate templates — use variants.
type EventType = "upgraded" | "downgraded" | "renewed" | "cancelled";
interface Props {
event: EventType;
planName: string;
effectiveDate: string;
}
export default function SubscriptionUpdate({
event,
planName,
effectiveDate,
}: Props) {
// Variant-specific content
const content = {
upgraded: {
heading: "Welcome to " + planName,
body: "You've upgraded your plan. All new features are now available.",
cta: "Explore new features",
tone: "positive",
},
downgraded: {
heading: "Plan updated",
body: `You've switched to ${planName}. Changes take effect on ${effectiveDate}.`,
cta: "View your plan",
tone: "neutral",
},
renewed: {
heading: "Subscription renewed",
body: `Your ${planName} plan has been renewed. Thanks for staying with us!`,
cta: "View receipt",
tone: "positive",
},
cancelled: {
heading: "Subscription cancelled",
body: `Your plan will remain active until ${effectiveDate}. You can reactivate anytime.`,
cta: "Reactivate subscription",
tone: "neutral",
},
}[event];
const bgColor = content.tone === "positive" ? "#f0fdf4" : "#f9fafb";
return (
<Email>
<Section style={{ backgroundColor: bgColor, padding: "32px" }}>
<Heading>{content.heading}</Heading>
<Text>{content.body}</Text>
<Button href={content.cta}>{content.cta}</Button>
</Section>
</Email>
);
}5) Progressive Disclosure — Expand Details on Demand
Email clients don't support interactive JavaScript, but you can still create expandable sections with pure CSS using the details element (limited support) or a "View full details" link that jumps to an expanded section.
Practical approach: Keep summaries short in the email, link to a full details page.
export default function OrderShipped({ order, trackingUrl }: Props) {
return (
<Email>
<Section>
<Heading>Your order has shipped!</Heading>
<Text>
Track your package:{" "}
<Link href={trackingUrl}>{order.trackingNumber}</Link>
</Text>
{/* Summary: show 2-3 items max */}
<Text style={{ marginTop: "16px", fontWeight: 600 }}>
Items in this shipment:
</Text>
{order.items.slice(0, 3).map((item) => (
<Text key={item.id}>• {item.name}</Text>
))}
{/* Progressive disclosure: link to full order */}
{order.items.length > 3 && (
<Text style={{ marginTop: "8px" }}>
<Link href={`/orders/${order.id}`}>
View all {order.items.length} items →
</Link>
</Text>
)}
</Section>
</Email>
);
}Common Gotchas (and How to Avoid Them)
1) Conditional rendering breaks email client layouts
Some email clients (looking at you, Outlook) don't handle conditional <tr> or <td> elements well. Wrap conditionals around entire <Section> or <Row> components, not fragments of table markup.
// ❌ Bad: conditional td breaks Outlook
<Row>
<Column>Name</Column>
{showPrice && <Column>Price</Column>}
</Row>
// ✅ Good: conditional entire Row
{showPrice && (
<Row>
<Column>Name</Column>
<Column>Price</Column>
</Row>
)}2) Long lists destroy email performance
Rendering 100+ items in an email creates a massive DOM and slow load times. Cap lists at 5-10 items, then link to "View all."
// Limit list length
const displayItems = items.slice(0, 5);
const hasMore = items.length > 5;
return (
<>
{displayItems.map((item) => <ItemCard key={item.id} {...item} />)}
{hasMore && (
<Text>
<Link href="/orders">+ {items.length - 5} more items</Link>
</Text>
)}
</>
);3) Dynamic content without preview data = broken dev experience
React Email's preview server needs sample data. If you don't provide defaults, your template won't render during development.
// Add default props for preview
export default function WelcomeEmail({
userName = "Alex",
accountType = "pro",
features = ["Feature A", "Feature B", "Feature C"],
}: Props) {
return <Email>{/* ... */}</Email>;
}
// Or use a separate preview data file
WelcomeEmail.PreviewProps = {
userName: "Alex Chen",
accountType: "pro",
features: ["Advanced analytics", "API access", "Priority support"],
} satisfies Props;Putting It Together: A Real Example
Here's a complete invoice email that uses multiple patterns: conditional blocks, dynamic lists, fallbacks, and variants.
interface Props {
invoiceNumber: string;
status: "paid" | "pending" | "overdue";
items: Array<{ id: string; name: string; quantity: number; price: number }>;
subtotal: number;
tax?: number;
discount?: { code: string; amount: number };
total: number;
paymentMethod?: string;
dueDate?: string;
}
export default function Invoice({
invoiceNumber,
status,
items,
subtotal,
tax,
discount,
total,
paymentMethod,
dueDate,
}: Props) {
// Variant content based on status
const statusConfig = {
paid: { heading: "Payment received", color: "#10b981" },
pending: { heading: "Invoice pending", color: "#f59e0b" },
overdue: { heading: "Payment overdue", color: "#ef4444" },
}[status];
return (
<Email>
<Section>
<Heading>Invoice #{invoiceNumber}</Heading>
<Text style={{ color: statusConfig.color, fontWeight: 600 }}>
{statusConfig.heading}
</Text>
</Section>
{/* Dynamic list: line items */}
<Section>
<Heading as="h2">Items</Heading>
{items.map((item) => (
<Row key={item.id}>
<Column style={{ width: "60%" }}>
<Text>{item.name}</Text>
</Column>
<Column style={{ width: "20%", textAlign: "center" }}>
<Text>×{item.quantity}</Text>
</Column>
<Column style={{ width: "20%", textAlign: "right" }}>
<Text>${item.price.toFixed(2)}</Text>
</Column>
</Row>
))}
</Section>
{/* Conditional: discount section */}
{discount && (
<Section style={{ backgroundColor: "#f0fdf4", padding: "16px" }}>
<Text>
Discount applied: <Strong>{discount.code}</Strong> (-$
{discount.amount.toFixed(2)})
</Text>
</Section>
)}
{/* Totals with optional tax */}
<Section>
<Row>
<Column style={{ textAlign: "right" }}>
<Text>Subtotal: ${subtotal.toFixed(2)}</Text>
{tax && <Text>Tax: ${tax.toFixed(2)}</Text>}
<Strong>Total: ${total.toFixed(2)}</Strong>
</Column>
</Row>
</Section>
{/* Conditional: payment info for paid invoices */}
{status === "paid" && paymentMethod && (
<Section>
<Text style={{ color: "#666", fontSize: "14px" }}>
Paid with {paymentMethod}
</Text>
</Section>
)}
{/* Conditional: due date for pending/overdue */}
{(status === "pending" || status === "overdue") && dueDate && (
<Section>
<Text>
{status === "overdue" ? "Was due:" : "Due:"} {dueDate}
</Text>
{status === "overdue" && (
<Button href="/pay">Pay now</Button>
)}
</Section>
)}
</Email>
);
}This template handles multiple edge cases cleanly: missing tax, missing discounts, different payment states, and variable-length item lists.
Testing Dynamic Content
Static templates are easy to test. Dynamic ones need edge case coverage.
- Empty states: Zero items, no discount, missing optional fields
- Max states: 100+ items (does it break?), very long strings
- Null/undefined: Every optional prop set to null
- Variants: Test each status/type combination
test-fixtures.ts file with realistic data variations, then render each one and snapshot the HTML output.For more on testing, see Testing React Email Templates.
When to Split Templates vs. Use Variants
Not everything should be one mega-template with 20 conditionals. Know when to split.
- Use variants when structure is 80%+ the same (status changes, copy swaps)
- Split templates when layouts diverge significantly (onboarding vs. billing vs. notifications)
Example: "New comment" and "New mention" notifications can share a template with a type prop. But "Trial ending" and "Invoice overdue" should be separate — their urgency, tone, and structure are too different to reconcile cleanly.
Production Checklist
Before shipping dynamic templates, verify:
- ✅ All optional props have fallback logic or safe defaults
- ✅ Empty arrays render gracefully (no broken "0 items" sections)
- ✅ Long lists are capped with "View more" links
- ✅ Preview data exists for every template (local dev works out of the box)
- ✅ Edge cases tested: null, undefined, empty, max length
- ✅ Email client compatibility checked (conditional layouts tested in Outlook)
If you're shipping production emails, start from battle-tested templates: Transactional Email Templates for Next.js.
Final Thoughts
Dynamic content patterns turn rigid templates into resilient systems. The five patterns here — conditional blocks, dynamic lists, fallback chains, variant switching, and progressive disclosure — handle 90% of real-world email scenarios.
The key: think defensively. Production data is messy, users do unexpected things, and email clients are unforgiving. Build templates that handle edge cases gracefully, and you'll spend less time debugging and more time shipping.