mailshot

Templates

React Email components, Liquid rendering, and the template lifecycle.

Templates are React Email components that get rendered to static HTML with Liquid placeholders. At runtime, LiquidJS fills in subscriber data before sending.

Lifecycle

.tsx (React Email)
  │  build step (@react-email/render)

.html (static HTML with {{ liquid }} placeholders)
  │  CDK BucketDeployment

S3 (stored at <sequenceId>/<templateName>.html)
  │  SendEmailFn fetches at runtime (10min cache)

LiquidJS renders with subscriber data


SES sends the final HTML

Writing a template

Templates live at sequences/<sequenceId>/src/emails/<templateName>.tsx.

import { Body, Container, Head, Html, Preview, Text, Link } from "@react-email/components";

interface Props {
  firstName?: string;
  unsubscribeUrl?: string;
}

export default function Welcome({
  firstName = "{{ firstName }}",
  unsubscribeUrl = "{{ unsubscribeUrl }}",
}: Props) {
  return (
    <Html>
      <Head />
      <Preview>Welcome aboard</Preview>
      <Body>
        <Container>
          <Text>Hey {firstName},</Text>
          <Text>Thanks for signing up.</Text>
          <Text style={{ fontSize: "12px", color: "#666" }}>
            <Link href={unsubscribeUrl}>Unsubscribe</Link>
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

Key conventions:

  • Default prop values use Liquid syntax: firstName = "{{ firstName }}". This means the React Email dev server shows the Liquid placeholders, and at build time they're baked into the HTML.
  • Always include a <Preview> component for preheader text.
  • Always include an unsubscribe link using {{ unsubscribeUrl }}.

Liquid syntax at runtime

The rendered HTML contains Liquid placeholders that LiquidJS evaluates at send time. You have access to all subscriber attributes:

<p>Hey {{ firstName }},</p>

{% if platform == "kajabi" %}
<p>Here's how to connect your Kajabi checkout...</p>
{% endif %}

{% for item in cartItems %}
<p>{{ item.name }} — ${{ item.price }}</p>
{% endfor %}

<a href="{{ unsubscribeUrl }}">Unsubscribe</a>

Available variables

VariableSource
firstNameSubscriber profile
emailSubscriber profile
unsubscribeUrlGenerated by SendEmailFn (HMAC-signed, 90-day expiry)
Any attribute keyFrom subscriber.attributes in DynamoDB

Display names

If you need human-readable labels for attribute values (e.g., showing "Kajabi" instead of "kajabi"), upload a JSON mapping to S3. The display-names lib module loads these with a 10-minute cache.

Template keys

A template key is the S3 path without the .html extension:

templateKey: "onboarding/welcome"
→ S3 path: s3://bucket/onboarding/welcome.html

Template keys in the sequence config must match the rendered HTML filename exactly.

Build step

Each sequence has a render.ts script that uses @react-email/render to convert .tsx to .html:

pnpm --filter @mailshot/<sequenceId> build

This runs tsc (compile TypeScript) then pnpm render (execute render.ts). Output goes to build/<sequenceId>/templates/.

Dev server

Preview templates in the browser with hot reload:

pnpm --filter @mailshot/<sequenceId> dev

Opens React Email dev server on port 3002. Shows all templates in src/emails/ with live preview.

Previewing with subscriber data

Use the MCP server's preview_template tool to render a template with real subscriber data:

preview_template(templateKey: "onboarding/welcome", email: "user@example.com")

This fetches the template from S3 and renders it with the subscriber's actual profile attributes.

Validating templates

Use the MCP server's validate_template tool to check that a template exists in S3 and renders without errors:

validate_template(templateKey: "onboarding/welcome")