[React Email](https://react.email) lets you build emails as React components — shared layouts, a real design system, a hot-reloading preview server — instead of hand-maintaining table-based HTML. SendOps deploys **Handlebars** templates to SES from a git repository.

These two fit together better than they look, because they run at different times. React Email renders **at build time**, turning your components into a static HTML file. Handlebars renders **at send time**, inside SES, filling that HTML with per-recipient data. So your build's job is not to *replace* Handlebars — it's to **emit** it. A React Email component that outputs the literal text `{{firstName}}` produces a file that is both finished HTML and a valid Handlebars template.

This guide covers the whole path: the helper that bridges JSX to Handlebars, the escaping rules that decide what survives rendering, the export script that writes your [manifest](/templates/manifest-file), and the GitHub Action that keeps it all current.


  Every notification SendOps sends — invitations, billing alerts, digests — is authored this way and deployed to SES through SendOps itself. The patterns below are the ones we run in production.


## How the pipeline fits together

Two commits, two systems, no manual steps:

1. You edit a `.tsx` component and push to `main`.
2. **CI** renders the components to HTML, writes a `sendops.json` manifest, and commits both into a generated folder (conventionally `sendops/`).
3. **SendOps** sees the push to that folder, validates the templates, and deploys them to SES.

The rendered folder is a **build artifact that happens to live in git**. It's the handoff point between your build and SendOps, and you never edit it by hand.


```
repo/
├── emails/                        ← you edit this
│   ├── emails/welcome.tsx
│   ├── components/Layout.tsx
│   ├── lib/vars.ts                ← the Handlebars bridge
│   ├── scripts/export.mjs         ← writes the manifest
│   └── package.json
├── sendops/                       ← CI generates this; never hand-edit
│   ├── sendops.json
│   └── templates/welcome.html
└── .github/workflows/sendops.yml
```


## The variable bridge

You cannot write `{{firstName}}` directly in JSX. JSX reads the outer brace as the start of an expression and the inner brace as an object literal, so `{{firstName}}` means "an object" — React throws rather than printing the text you wanted.

Instead, return the token as a **string** from a tiny helper. Create `lib/vars.ts`:

```ts title="lib/vars.ts"
/** Emit a Handlebars variable token, e.g. cp("firstName") → "{{firstName}}" */
export const cp = (name: string) => `{{${name}}}`
```

Now use it anywhere you'd use a value — in text, in props, in template literals:

```tsx title="emails/welcome.tsx"




const Welcome = () => (
  
    <Text>
      Hi <strong>{cp("firstName")}</strong>, welcome to {cp("orgName")}.
    </Text>
    <Button href={`${cp("appUrl")}/onboarding?token=${cp("token")}`}>
      Get started
    </Button>
  
)

export default Welcome
```

That renders to a Handlebars template SES understands:

```html
Hi <strong>{{firstName}}</strong>, welcome to {{orgName}}.
Get started
```

The helper isn't magic — `"{{firstName}}"` as a plain string literal works identically. What it buys you is one place to grep for every variable your emails depend on, and a natural home for the guarded variants below.

## What survives rendering, and what doesn't

React escapes text before it reaches the HTML, and that escaping is the one thing that can silently corrupt a template. The rule is simple once you see it: **React escapes `&`, `<`, `>`, `"` and `'`. Handlebars tokens that contain none of those pass through untouched.**

| What you write | In HTML | Safe? |
|---|---|---|
| `{cp("firstName")}` | `{{firstName}}` | Yes |
| `{"{{#each items}}"}` | `{{#each items}}` | Yes |
| `href={cp("appUrl") + "/x"}` | `href="{{appUrl}}/x"` | Yes |
| `{'{{fmt d "YYYY"}}'}` | `{{fmt d &quot;YYYY&quot;}}` | **No — broken** |

Anything with a **quoted argument**, a comparison, or an `&` gets mangled — in attributes just as much as in text. When you need one, bypass React's escaping with `dangerouslySetInnerHTML`, which writes the string through verbatim:

```tsx

```


  A corrupted token still looks perfect in your source and in the React Email preview — `&quot;` only appears in the exported file. When a template misbehaves, grep the generated HTML for `&quot;` and `&amp;` before anything else.


## Loops and conditionals

Handlebars block helpers are just text, so you emit the opening and closing tokens as **siblings** around real JSX elements. React renders them in place and SES sees a well-formed block:

```tsx

  <tbody>
    {"{{#each items}}"}
    <tr>
      <td>{cp("this.name")}</td>
      <td>{cp("this.price")}</td>
    </tr>
    {"{{/each}}"}
  </tbody>

```

```html title="rendered"
{{#each items}}<tr><td>{{this.name}}</td><td>{{this.price}}</td></tr>{{/each}}
```

Your JSX is no longer a tree that matches the output one-to-one — the `<tr>` appears once but sends N times — which is the price of moving the loop to send time. If a block gets hairy, the alternative is to have your application pre-render the rows and inject the whole fragment through a single variable with `dangerouslySetInnerHTML`.

## Guarding variables against strict render

This is the failure mode that costs people the most time.

**SES renders strictly.** If a deployed template references a variable that a particular send doesn't supply, SES does not fall back to blank — it accepts your API call, returns `200` with a message ID, and then **drops the message**, surfacing a `Rendering Failure` event afterwards. The send looks successful and the email never arrives.

Any variable that isn't guaranteed on *every* send path must therefore be guarded. Put the guarded variants next to `cp()` so they're impossible to miss:

```ts title="lib/vars.ts"
export const cp = (name: string) => `{{${name}}}`

/** A variable with a fallback, for sends that may not supply it. */
export const cpOr = (name: string, fallback: string) =>
  `{{#if ${name}}}{{${name}}}{{else}}${fallback}{{/if}}`
```

```tsx title="components/Footer.tsx"
// Test sends and broadcasts don't supply appUrl — guard it or they vanish.
Manage preferences
```

Shared components — footers, headers, anything on every email — are where this bites hardest, because they inherit the union of every send path's assumptions. Guard variables there by default.


  A variable your production code always sets is easy to forget in a [test data profile](/templates/template-versioning). Give every template a fixture that supplies its full variable set, and the previews will tell you before your recipients do.


## Exporting and building the manifest

`email export` renders every component in `emails/` to static HTML in `./out`. A short script then moves those files into the folder SendOps watches and writes the [`sendops.json` manifest](/templates/manifest-file) that names each template and its subject.

```js title="scripts/export.mjs"



const outDir = "./out"
const sendopsDir = "../sendops"

// Subjects live here so they're versioned with the template they belong to.
const subjects = {
  welcome: "Welcome to {{orgName}}",
  "password-reset": "Reset your password",
}

const files = readdirSync(outDir).filter((f) => f.endsWith(".html"))
if (files.length === 0) {
  console.error("export: ./out is empty — run `email export` first")
  process.exit(1)
}

// Rebuild from scratch so deleted emails disappear from the manifest too.
rmSync(join(sendopsDir, "templates"), { recursive: true, force: true })
mkdirSync(join(sendopsDir, "templates"), { recursive: true })

const templates = {}
for (const file of files.sort()) {
  const name = file.replace(/\.html$/, "")
  cpSync(join(outDir, file), join(sendopsDir, "templates", file))
  templates[name] = { path: `templates/${file}` }
  if (subjects[name]) templates[name].subject = subjects[name]
}

writeFileSync(
  join(sendopsDir, "sendops.json"),
  JSON.stringify({ templates }, null, 2) + "\n",
)
console.log(`export: wrote ${files.length} templates + sendops.json`)
```

Two details worth copying. **Deleting the output directory before writing** means a removed `.tsx` drops out of the manifest automatically, rather than lingering in SES forever. And **failing loudly on an empty `./out`** stops a broken render from committing an empty manifest, which SendOps would read as "delete everything".

Bake environment-specific URLs in at render time, and keep the whole thing behind one command:

```makefile title="Makefile"
export_sendops:
	rm -rf ./out
	APP_URL=https://app.example.com pnpm exec email export
	node scripts/export.mjs
```

Set the `subject` for every template you actually send — without one, SES falls back to using the template *name* as the subject line. If your templates reference images, list them under the manifest's `images` key to promote them to your [Edge CDN](/edge/asset-library).

## Wiring up CI

The action renders on every push that touches your email source and commits the result back:

```yaml title=".github/workflows/sendops.yml"
name: SendOps Templates

on:
  workflow_dispatch:
  push:
    branches: [main]
    paths:
      - 'emails/**'                            # NOT sendops/**

concurrency:                                   # serialize: no racing commits
  group: ${{ github.workflow }}
  cancel-in-progress: false

jobs:
  export:
    runs-on: ubuntu-24.04
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v5
        with:
          ref: main
      - uses: pnpm/action-setup@v4
        with:
          package_json_file: emails/package.json
      - uses: actions/setup-node@v5
        with:
          node-version: 22
          cache: pnpm
          cache-dependency-path: emails/pnpm-lock.yaml
      - run: pnpm install --frozen-lockfile
        working-directory: emails
      - run: make export_sendops
        working-directory: emails

      - name: Commit artifacts
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add sendops
          if git diff --staged --quiet; then
            echo "sendops/ unchanged — nothing to commit"
            exit 0
          fi
          git commit -m "chore(sendops): re-render email templates"
          git pull --rebase origin main
          git push origin main
```


  This workflow commits to `sendops/**` and triggers on `emails/**`. If those overlapped, the bot's own commit would re-trigger the workflow and loop forever. Check the trigger paths of your *other* workflows too — any of them that watches the generated folder will now fire on every email change.


The `concurrency` group matters for a subtler reason: two pushes landing close together would otherwise race to commit the same folder, and the loser fails on a non-fast-forward push. Serializing the workflow makes that impossible.

## Connecting the repository


  <Step title="Merge the generated folder to main">
    Run your export locally once and commit `sendops/` so the repository has a manifest to sync on the very first connection.
  </Step>

  <Step title="Connect the repo in SendOps">
    On the **Templates** page, click **Connect Repository** and install the SendOps GitHub App. See [Template Management](/templates/template-management) for the full walkthrough.
  </Step>

  <Step title="Set the Subfolder to your generated folder">
    Set **Subfolder** to `sendops`. Every path inside the manifest is then resolved relative to that folder — which is why the manifest above writes `templates/welcome.html` and not `sendops/templates/welcome.html`.
  </Step>

  <Step title="Push and watch the sync">
    Change an email, merge to `main`, and CI commits the re-rendered HTML. SendOps picks up that commit, validates each template, and deploys the valid ones to SES. Failures appear in the sync history with line and column numbers.
  </Step>


## Before you ship

- Every unguaranteed variable is wrapped in `cpOr` — especially in shared headers and footers.
- The exported HTML contains no `&quot;` or `&amp;` inside `{{ }}`.
- Every template you send has a `subject` in the manifest.
- The generated folder is absent from every workflow's trigger paths.
- Each template has a [test data profile](/templates/template-versioning) covering its full variable set.
- Nobody is editing the generated folder by hand — add a `CODEOWNERS` entry or a header comment if that's a real risk on your team.

## What's next?

- [Manifest File](/templates/manifest-file) — the full `sendops.json` reference, including test data, images, and consent classes.
- [Previews & Test Data](/templates/template-versioning) — catch missing variables before SES drops the send.
- [Pull Requests & Compare](/templates/github-integration) — see rendered diffs of a template change on the PR.
- [Asset Library & Images](/edge/asset-library) — serve logos and icons referenced by your components.