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 HTMLWriting 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
| Variable | Source |
|---|---|
firstName | Subscriber profile |
email | Subscriber profile |
unsubscribeUrl | Generated by SendEmailFn (HMAC-signed, 90-day expiry) |
| Any attribute key | From 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.htmlTemplate 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> buildThis 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> devOpens 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")