Code Tips12 min read

Dynamic Content Patterns in React Email: 5 Patterns That Handle Real-World Data

Master conditional rendering, dynamic lists, fallback chains, variant switching, and progressive disclosure in React Email. Practical patterns for handling messy production data without template chaos.

R

React Emails Pro

February 28, 2026

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.

Dynamic content patterns are the difference between brittle templates that break on edge cases and resilient ones that handle the messiness of production data.

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.

emails/order-confirmation.tsx
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>
  );
}
Why this works: Entire blocks appear/disappear cleanly. No empty divs, no broken layouts. If shipping doesn't exist, users never see a "Shipping: undefined" section.

2) Dynamic Lists — Render Variable-Length Data

When you need to render an unknown number of items — order line items, activity logs, recommended products.

emails/receipt.tsx
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?

emails/activity-digest.tsx
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>
  );
}
Common mistake: Forgetting the empty state. An email with zero items and no fallback looks broken. Always handle the empty array case.

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.

emails/team-invite.tsx
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>
  );
}
Pro pattern: Extract fallback logic into helper functions when you use it across multiple templates. Create 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.

emails/subscription-update.tsx
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>
  );
}
When to use variants: When the structure is the same but copy and tone differ dramatically. If the layouts diverge too much, create separate template files instead.

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.

emails/order-shipped.tsx
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>
  );
}
Why link out: Email real estate is limited. Summaries keep the email scannable; full details live on the web where users can interact properly.

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.

emails/example.tsx
// 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;
Without preview defaults, you'll be debugging blind. Always include realistic sample data for local development.

Putting It Together: A Real Example

Here's a complete invoice email that uses multiple patterns: conditional blocks, dynamic lists, fallbacks, and variants.

emails/invoice.tsx
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
Write snapshot tests for each edge case. Create a 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.

Rule of thumb: If adding one more conditional makes you lose track of which variant is rendering, split the template.

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.

Want to see these patterns in action? Check out our SaaS template library — every template uses these patterns for real-world data handling.

Production-ready templates for every flow

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates