# Resend のメールテンプレート設計：React Email と Templates API を本番で壊さず育てる

> メール本文をコード（React Email）に置くか、Resend Templates（ダッシュボード）に置くか。三重波括弧の変数記法・draft/publish・render の現行シグネチャ・Tailwind の制約まで、公式ドキュメントと resend@6.4.1 の型に忠実に、本番で壊さない設計を示します。

- 公開日: 2026-08-06
- 著者: 友田 陽大
- タグ: Resend, React Email, メール配信, TypeScript, Next.js, 到達率, テスト
- URL: https://tomodahinata.com/blog/resend-templates-variables-react-email-design-guide
- カテゴリ: Resend・メール配信
- 総合ガイド: https://tomodahinata.com/blog/resend-transactional-email-production-guide

## 要点

- メール本文の置き場所は3択（インラインHTML文字列 / React Email / Resend Templates）。選定軸は「誰が文面を直すか」と「直すのにデプロイが要るか」の2つで、技術的な好みではなく運用体制で決まる
- React Email は 6.0（2026-04-16）で単一パッケージ react-email に統合された。@react-email/components からの import と renderAsync は過去のもので、現行は react-email からの import と async な render のみ
- Resend Templates は draft と published が分かれる。update は draft にしか書き込まれず、publish して初めて送信に反映される。送信時は template と html/react/text が型レベルで排他になる
- 変数は三重波括弧。key は ASCII英数字とアンダースコアのみ50文字以内、値は文字列2,000文字以内。FIRST_NAME・LAST_NAME・EMAIL・UNSUBSCRIBE_URL 系は予約語で使えない
- メールHTMLは Web の常識が通じない。Gmail は102KBで本文をクリップし、Tailwind の rem は一部クライアントで効かないため pixelBasedPreset が要る。プレーンテキスト版は到達率とアクセシビリティの両方の保険になる

---

メールの HTML は、2026年の Web の HTML とは別の言語です。Flexbox も Grid も動くクライアントと動かないクライアントがあり、`rem` すら安全ではありません。そして厄介なことに、**メール本文は「一度書いて終わり」にならない**。価格が変わり、文言が変わり、法務が一文足せと言い、英語版が要る。

この記事が解くのは、**その変更をどこで受け止めるか**という設計の問題です。本文を TypeScript のコードとして持つのか、Resend のダッシュボードに置いて非エンジニアに渡すのか。選択を誤ると「文言をひとつ直すのに本番デプロイが必要」か、逆に「誰がいつ何を変えたのか誰も追えない」のどちらかに落ちます。

私はこのポートフォリオサイト自体を Resend で本番運用しています（問い合わせ・リードマグネット配布・7通のメール講座・Stripe 決済後の納品メール）。現時点の実装は後述する「インライン HTML 文字列」方式で、その利点と限界の両方を実地で味わっています。以下は **Resend / React Email の公式ドキュメント（2026年8月時点）と、インストール済み `resend@6.4.1` の型定義**に忠実な内容に、その運用判断を重ねたものです。全体像は[Resend 本番運用ガイド](/blog/resend-transactional-email-production-guide)にまとめています。

---

## メール本文の置き場所は3択しかない

技術的な選択肢は無数にあるように見えて、運用上は3つです。判断軸は「誰が文面を直すか」と「直すのにデプロイが要るか」に集約されます。

| 観点 | インライン HTML 文字列 | React Email（コード） | Resend Templates（ダッシュボード） |
|---|---|---|---|
| 誰が編集するか | 開発者のみ | 開発者のみ | 開発者 + 非エンジニア |
| 差分レビュー | Git で可能（読みにくい） | Git で可能（JSX として読める） | バージョン履歴で可能（コードレビューは通らない） |
| 型安全 | なし（文字列連結） | あり（props の型で担保） | 部分的（変数の key と type のみ） |
| i18n | 自前でロケール分岐 | props かロケール別コンポーネント | 言語ごとにテンプレートを複製 |
| デプロイ不要で直せるか | 不可 | 不可 | 可能（publish するだけ） |
| テストのしやすさ | 文字列比較のみ | render のスナップショットが取れる | 公式のプログラマティックな手段は未提供 |
| 導入コスト | ゼロ | 依存追加 + プレビュー環境 | Full access の API キーが必要 |

私がこのサイトで1番目を選んだ理由は単純で、**文面を触るのが私一人だから**です。`lib/email-course-render.ts` は本文を段落に割ってインラインスタイル付きの `<p>` に変換し、特定電子メール法対応のフッターを差し込み、**HTML とプレーンテキストを必ずペアで返す**純粋関数になっています。

```ts
// lib/email-course-render.ts（本番稼働中・抜粋）
export function renderDay(day: EmailCourseDay, unsubUrl: string): { html: string; text: string } {
  const footer = buildFooter(unsubUrl);
  // text 版はフッターのプレースホルダを差し替えるだけ。HTML からの自動生成に任せると
  // 配信停止URLがどこに出るか（＝読み上げ順）を保証できないため、自分で持つ。
  const text = day.bodyMarkdown.replace(EMAIL_COURSE_FOOTER_PLACEHOLDER, footer.text);
  // ...段落を <p style="..."> に変換して結合...
  return { html, text };
}
```

この方式の限界も正直に書きます。**文言をひとつ直すのにデプロイが要り、デザインの意図がスタイル文字列に埋もれる**。文面を回す人が増えた瞬間に破綻します。

---

## React Email：本文をコードとして持つ

### まず2026年の前提に更新する

React Email は 6.0（2026-04-16）で**パッケージ構成が根本から変わりました**。ここが古いままだと、公式サンプルをコピーしても動きません。

| 古い理解（捨てる） | 2026年の正しい理解（公式） |
|---|---|
| `@react-email/components` から import | すべて `react-email` から import。個別パッケージも統合済み |
| `@react-email/render` から render を import | `render` も `react-email` から export される |
| `renderAsync` を使う | 5.0 で削除。`render` に一本化（`render` 自体が async） |
| `@react-email/preview-server` | `@react-email/ui` にリネーム |
| Tailwind は v3 前提 | Tailwind コンポーネントが使うのは tailwindcss 4.1.12 |
| `className` はコンポーネントにも style をインライン化 | **要素にのみ**インライン化される |

```bash
npm uninstall @react-email/components @react-email/preview-server
npm install react-email@latest @react-email/ui@latest
```

なお npm 上の `react-email` の最新は 6.9.1（2026-07-23 公開）ですが、**公式チェンジログは 6.0.0 で止まっています**。6.1〜6.9 の変更点は公開情報から確認できないため、この記事ではそれらのバージョン固有の挙動には触れません。

### 最小のテンプレート

```tsx
// emails/order-confirmation.tsx
import { Body, Button, Container, Head, Heading, Html, Img, Preview,
         Section, Tailwind, Text, pixelBasedPreset } from "react-email";
import * as React from "react";

interface Props { customerName?: string; productName?: string; receiptUrl?: string }

export default function OrderConfirmation({
  // 既定値はプレビュー時の見栄えと、props 欠落時の事故防止を兼ねる
  customerName = "お客様", productName = "商品", receiptUrl = "https://example.com/receipt",
}: Props) {
  return (
    // lang / dir は Html と Body の両方に付ける。
    // 一部のクライアントは html / body タグ自体を剥がすため、片方だけでは残らない。
    <Html lang="ja" dir="ltr">
      <Head />
      {/* pixelBasedPreset が無いと Tailwind は rem を吐き、rem 非対応クライアントで崩れる */}
      <Tailwind config={{ presets: [pixelBasedPreset] }}>
        {/* プレビューテキストは90文字以内が公式の推奨 */}
        <Preview>{productName}のご注文を承りました</Preview>
        <Body lang="ja" dir="ltr" className="bg-white font-sans">
          <Container className="mx-auto py-12">
            {/* svg はサポートが弱いので png / gif / jpg。src は本番の絶対URL */}
            <Img src="https://cdn.example.com/logo.png" alt="Acme" width="120" />
            <Heading className="text-2xl font-semibold">{customerName}さま、ありがとうございます</Heading>
            <Text className="text-base text-zinc-700">{productName}の決済が完了しました。</Text>
            <Section className="mt-6">
              {/* Button は a 要素として描画される。押せない画像ボタンにしないこと */}
              <Button className="rounded-md bg-black px-5 py-3 text-white" href={receiptUrl}>
                領収書を表示する
              </Button>
            </Section>
          </Container>
        </Body>
      </Tailwind>
    </Html>
  );
}
```

コンポーネント側の制約で押さえるべきものは次の通りです。`Html` / `Body` は `lang`（既定 `en`）と `dir`（既定 `ltr`）を両方に付ける。`Preview` は90文字以内。`Img` は `.png` / `.gif` / `.jpg` が全クライアントで表示でき、**`.svg` は参照方法によらずサポートが弱いので避ける**。`Button` / `Link` は `href` 必須で `target` の既定は `_blank`。`Font` は `<Head>` の中に置き、Web フォント非対応クライアント向けに `fallbackFontFamily` を必ず設定する。`Heading` だけは `as`（`h1`〜`h6`）に加えて `m` / `mx` / `my` / `mt` / `mr` / `mb` / `ml` のマージン短縮 props を持ちます。公式は各コンポーネントを **Gmail・Apple Mail・Outlook・Yahoo! Mail・HEY・Superhuman** でテスト済みと明記しています。

### render は非同期。pretty と toPlainText は別関数

```tsx
import { render, pretty, toPlainText } from "react-email";

// render は Promise<string>。await を忘れると "[object Promise]" が本文になる。
const html = await render(<OrderConfirmation customerName="友田" productName="Aegis" />);
const readable = await pretty(html); // prettier の format() を返すので Promise
const text = toPlainText(html);      // こちらは同期。既定で img をスキップする
```

`render` のオプションは `pretty`（整形）・`plainText`（テキストで返す）・`htmlToTextOptions` の3つ。内部では XHTML 1.0 Transitional の DOCTYPE を先頭に付け替え、画像のプリロードリンクを除去します（`plainText: true` のときは DOCTYPE を付けません）。ブラウザで動かす場合のみ、Safari と iOS 向けに `web-streams-polyfill` が必要だと公式が明記しています。

### 送信の2通りと、私が後者を推す理由

```tsx
// 方法A：SDK に描画させる（公式の推奨形）
await resend.emails.send({ from, to, subject, react: <OrderConfirmation productName="Aegis" /> });
```

公式は「他サービスと連携するときは React テンプレートを HTML に変換する必要があるが、**Resend はそれを代行する**」と書いています。ただし本番では、私は次を推します。

```tsx
// 方法B：自分で描画して html と text を両方渡す（実務判断。公式の推奨ではない）
import { render, toPlainText } from "react-email";

const html = await render(<OrderConfirmation customerName="友田" productName="Aegis" />);

const { data, error } = await resend.emails.send({
  from: "Acme <onboarding@resend.dev>",
  to: ["delivered@resend.dev"],
  subject: "ご注文ありがとうございます",
  html,
  // text を省くと HTML からの自動生成に任せることになる。
  // 到達率チェックでも「プレーンテキスト版を含めること」が挙げられている。
  text: toPlainText(html),
});

// SDK はネットワーク層の例外を除いて throw しない。必ず error を分岐する。
if (error) console.error("[email] phase=send_error", { name: error.name });
```

理由は3つ。**プレーンテキスト版を自分の手で持てる**こと、描画が純粋関数になって送信と別々に検証できること、そして最も実務的なのが **SDK と React Email のバージョン結合を外せる**ことです。

これは推測ではなく、インストール済み `resend@6.4.1` の実装から確認できます。このバージョンは `react:` を渡されると `@react-email/render` を動的 import し（`peerDependencies` に `@react-email/render: "*"`、`peerDependenciesMeta` で optional）、`templates.create({ react })` 側は **`renderAsync`** を import しようとします。ところが `renderAsync` は React Email 5.0 で削除され、`@react-email/render` というパッケージ自体も 6.0 で `react-email` に統合されました。この組み合わせでは `react:` の描画が「Failed to render React component」で**例外として throw されます**（`{ data, error }` に入らない、数少ない throw 経路です）。npm 上の `resend` 最新は 6.18.1（2026-07-28 公開）で、この配線が更新済みかは未検証です。**自分で `render()` して `html` を渡す方法Bは、この結合そのものを回避します。**

### プレビューは `email dev`

```bash
npx email dev                          # 既定で ./emails を監視し http://localhost:3000 で開く
npx email dev --dir src/emails --port 3001
```

ツールバーには **Linter**（本文とリンクの検査）・**Compatibility**（caniemail による HTML/CSS 対応状況）・**Spam**（スパムフィルタからの見え方）に加え、**Resend タブ**（Upload / Bulk Upload でテンプレートを Resend に取り込む）が並びます。

落とし穴が2つ。ひとつは**静的ファイル**で、`emails/static` の画像は `http://localhost:3000/static/...` で配信されますが、公式が明示する通り「**これはあなたの画像がホストされることを意味しない**」——そのまま送ると受信箱では表示されません。公式は `process.env.NODE_ENV === "production"` のときだけ CDN の絶対 URL を前置する `baseURL` パターンを示しています。もうひとつは**ファイル判定**で、プレビューサーバは「拡張子が `.js` / `.jsx` / `.tsx`」かつ「`export default` を含む」ファイルをメールと見なします。共有コンポーネントを一覧から隠したいなら、ディレクトリ名を `_components` のようにアンダースコアで始めてください。プレビュー専用 props は `Email.PreviewProps` に置けます。

---

## Resend Templates：本文をダッシュボードに置く

Templates は Resend 側に保存され、送信時は **id と変数だけを送る**仕組みです。公式の言葉では「HTML を送る代わりにテンプレートの `id` と `variables` だけを送れば、Resend が最終的なメールを描画して送信する」。推奨ユースケースはログイン/認証・オンボーディング・EC・通知・Automations です。

### 変数記法は三重波括弧

```ts
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

// create は publish をチェーンできる thenable を返す（作成と同時に公開）
const { data, error } = await resend.templates
  .create({
    name: "order-confirmation",
    alias: "order-confirmation",             // 送信時に id の代わりに使える
    from: "Acme Store <store@example.com>",  // 送信時に上書き可能な既定値
    subject: "ご注文ありがとうございます",      // 同上
    html: "<p>商品: {{{PRODUCT}}}</p><p>合計: {{{PRICE}}}円</p>", // 三重波括弧。二重ではない
    // text を渡さないと HTML から自動生成される。空文字を渡すと自動生成を無効化できる。
    text: "商品: {{{PRODUCT}}}\n合計: {{{PRICE}}}円",
    variables: [
      // Node SDK は camelCase の fallbackValue、REST は snake_case の fallback_value
      { key: "PRODUCT", type: "string", fallbackValue: "商品" },
      { key: "PRICE", type: "number", fallbackValue: 0 },
    ],
  })
  .publish();

// テンプレートの CRUD には Full access の API キーが必要。
// 送信専用キーだと restricted_api_key（401）で弾かれる。
if (error) console.error("[template] phase=create_error", { name: error.name });
```

変数まわりの仕様を、公式の記載どおりに整理します。

| 項目 | 仕様 |
|---|---|
| 記法 | 保存 HTML 内は三重波括弧。エディタでは波括弧2つで変数パレットが開く |
| 定義時のフィールド | `key`（慣例は大文字）・`type`（`string` または `number`）・フォールバック値 |
| フォールバック無し | 送信時に値を渡さないと**メールは送信されず検証エラー**になる |
| 送信時の key 制約 | ASCII 英字・数字・アンダースコアのみ。**50文字以内** |
| 送信時の値の制約 | 文字列は**2,000文字以内**、数値は **2^53 - 1 以下** |
| 予約語 | `FIRST_NAME` / `LAST_NAME` / `EMAIL` / `UNSUBSCRIBE_URL`（`RESEND_UNSUBSCRIBE_URL` 表記のページもある）/ `contact` / `this` |
| 変数の個数上限 | **API リファレンスは50、introduction ページのみ20** と記載が食い違う |

最後の2行は公式ドキュメント内で表記が割れている箇所です（2026-08-06 時点）。予約語は**両方の表記を避ける**のが安全で、個数上限は**20を超える設計にしない**のが実務的な逃げ方です。20個を超える変数が要るなら、それはテンプレートではなくコードで組むべき本文です。正確な最新値は[公式の該当ページ](https://resend.com/docs/dashboard/templates/template-variables)で確認してください。

### draft と published は別物

ここが Templates の設計の核心で、事故を防ぐ仕組みでもあります。

```text
create ──▶ [draft] ──publish──▶ [published]  ← 送信に使われるのはこちらだけ
                                     │
                    update ──▶ [draft(新)] ──┤  update は published を書き換えない
                                     │
                              publish ──────▶ [published(新)]

version history から revert すると「選択したバージョンを内容とする新しい draft」が
できる。published はその瞬間には変わらない。
```

公式の記述を噛み砕くと、テンプレートは**既定で draft** として作られ **publish するまで送信に使えない**。公開後の編集も**draft として保存され**、再度 publish するまで送信中のメールに影響しない。version history では各バージョンのプレビュー・作成者・日時が見え、revert もできるが **revert は新しい draft を作るだけで published には触れない**。この分離により「**変数を追加・削除しても既存のメールを壊さず、検証エラーも起こさない**」と明記されています。

つまり **`update` は「下書きへの書き込み」、`publish` は「本番リリース」**。この2語をコードレビューで意識的に区別してください。

```ts
await resend.templates.update("order-confirmation", { html: "<p>合計: {{{PRICE}}}円</p>" }); // draft
await resend.templates.publish("order-confirmation");                                        // 反映
```

パス上の識別子は**どのエンドポイントでも「ID またはエイリアス」**が使えます。UUID を散らかす代わりに `order-confirmation` のような alias を使うほうが、レビューでも読めます。

### 未公開の変更を CI で検知する（実務判断）

`GET /templates/{id}` のレスポンスには `status`・`published_at`・`current_version_id` に加えて **`has_unpublished_versions`（boolean）** が入ります。「publish し忘れた下書きが本番に残っている」ことを機械的に検知できる唯一の信号です。

```ts
/** publish 忘れを CI で落とすガード。list の項目には has_unpublished_versions が
 *  含まれない（SDK の型どおり）ため、一覧を取ってから ID ごとに get する必要がある。 */
export async function findUnpublishedTemplates(resend: Resend): Promise<string[]> {
  const { data: list, error } = await resend.templates.list({ limit: 100 });
  if (error || !list) throw new Error(`templates.list failed: ${error?.name ?? "unknown"}`);

  const stale: string[] = [];
  for (const item of list.data) {
    const { data: tpl } = await resend.templates.get(item.id);
    if (tpl?.has_unpublished_versions) stale.push(tpl.alias ?? tpl.id);
  }
  return stale;
}
```

これは公式が案内している運用ではなく、公開レスポンス項目から組み立てた実務上の仕掛けです。1件ずつ `get` するのでテンプレート数が多いとレート制限に触れます（[冪等性・リトライ設計の記事](/blog/resend-idempotency-retry-error-handling-reliability-guide)で扱っています）。

### 送信時：template は html / react / text と排他

ここは `resend@6.4.1` の型定義がそのまま仕様です。`CreateEmailOptions` は「本文系（`react` / `html` / `text` のいずれか必須）」か「`template`」かの**判別可能ユニオン**になっていて、両方渡すと**コンパイルが通りません**。API も検証エラーを返します。

```ts
const { data, error } = await resend.emails.send({
  to: ["delivered@resend.dev"],
  // テンプレートに from / subject の既定値があれば省略できる（型レベルで任意になる）
  template: {
    id: "order-confirmation", // UUID でも alias でもよい。ただし published のものだけ
    variables: { PRODUCT: "Vintage Macintosh", PRICE: 499 }, // 値の型は string | number
  },
  // html: "<p>...</p>",  ← これを足すと型エラー。API も validation error を返す
});

// 分類は name で行い、ステータスコードは補助情報として扱う
// （公式ドキュメントと SDK 定数で status が食い違う項目があるため）
if (error) console.error("[email] phase=template_send_error", { name: error.name });
```

優先順位も明快です。**payload の `from` / `subject` / `reply_to` はテンプレートの既定値より優先される**。テンプレート側に既定値が無ければ payload で必ず渡す必要があります。公式サンプルに `subject` を書かない送信例が出てくるのは、テンプレートが既定の件名を持っているからです。Templates は `/emails` と `/emails/batch` の**両方**でサポートされます（一括配信は[一括送信・予約送信・購読管理の記事](/blog/resend-batch-scheduled-broadcasts-audiences-topics-unsubscribe-guide)へ）。

### React Email から Templates へ持っていく

コードで骨格を作り、運用は非エンジニアに渡す中間解も用意されています。

```bash
npx react-email@latest resend setup   # プレビューサーバの Resend タブを有効化（Full Access のキー）
resend templates create --name "Welcome" --subject "Welcome to Acme" --react-email ./emails/welcome.tsx
resend templates publish <id>          # create しただけでは draft のまま
```

ダッシュボードのエディタにコードを貼る方法もありますが制約が明記されています——「**React Email のコードを貼り付ける場合、サポートされるのは `@react-email/components` と `react` からの import のみ。ローカルファイルの import（例: `./components/Logo`）やサードパーティパッケージはエディタでサポートされない**」。この文面が旧パッケージ名のままなのは公式内の不整合ですが、**共有コンポーネントを import した .tsx はそのままでは貼れない**という制約自体は現実として扱ってください。

---

## どちらを選ぶか

```text
文面を直すのは誰か？
├─ 開発者だけ
│   ├─ 本文がロジックを持つ（条件分岐・繰り返し・計算）
│   │   └─▶ React Email。render のスナップショットでテストする
│   └─ 本文が数行の定型文だけ
│       └─▶ インライン HTML 文字列で十分。text とペアで返す純粋関数にする
│
└─ 非エンジニアも直す
    ├─ 変更履歴と「戻せること」が必要 → Resend Templates（draft / publish / version history）
    ├─ デザインの骨格は開発者が管理したい
    │   └─▶ React Email で作り、CLI かプレビューサーバの Resend タブでアップロード
    └─ 変数が20個を超えそう
        └─▶ それはテンプレートではない。コード側に持ち帰る

多言語（ja / en）は？
├─ Templates → 言語ごとに別テンプレート（alias に -ja / -en を付ける）
└─ React Email → ロケールを props で受けるか、ロケール別コンポーネントに分ける
```

迷ったら **「この文面を、来月の私以外の誰かが直す可能性はあるか」** を自問してください。Yes なら Templates 側に寄せる価値があります。

---

## メール HTML の現実

**サイズ**：Gmail は**1通あたり 102 KB** を超えると本文をクリップし、残りを「メッセージ全体を表示」リンクの背後に隠します。Resend の Deliverability Insights はこのしきい値と現在のサイズを表示します。なお 102 KB は **Gmail のクリップしきい値であって Resend の API 上限ではありません**（`html` / `text` のサイズ上限は API としては非公開です）。

**単位**：Tailwind は既定で `rem` を使いますが、公式が明言する通り「**これをサポートしないメールクライアントがある**」ため、`pixelBasedPreset` で 16px 基準の px に変換します。任意ではなく事実上の必須設定です。

**タグの剥がれ**：`lang` / `dir` を `Html` と `Body` の両方に付ける理由は「**一部のクライアントは html タグや body タグを剥がす**」から。片方だけでは残らない可能性があります。

**画像**：`.svg` は避ける。そして前述の通り、**プレビューサーバの `static` は本番のホスティングではありません**——ローカルで見えていた画像が受信箱で消えるのは、この誤解が典型的な原因です。

**プレーンテキスト版**：Deliverability Insights の「Include Plain Text Version」は `text` パラメータで渡すこと、React Email のプレーンテキスト描画ユーティリティで生成できることを案内しています。`text` を省けば HTML から自動生成されますが、**空文字を渡すと自動生成そのものが無効化されます**（＝テキストパートを持たないメールになる。意図せずやると到達率に効きます）。

**CTA**：React Email の `Button` は `<a>` として描画され、Outlook 用の MSO 条件付きコメントが自動で挟まります。**押せる領域がテキストリンクとして成立していること**を生成 HTML で確認してください。

**ダークモード**については、Resend / React Email の公式ドキュメントに設計指針の記述を見つけられませんでした（2026-08-06 時点）。プレビューサーバの Compatibility パネルは caniemail のデータを使うので、個別プロパティの対応状況はそこで確認するのが確実です。**「ダークモードでも読める」ことは、記憶ではなく実機で確かめてください。** 到達率そのものの設計（SPF / DKIM / DMARC）は[ドメイン認証と到達率の記事](/blog/resend-domain-authentication-spf-dkim-dmarc-deliverability-guide)に分けています。

---

## アクセシビリティ：メールでも手を抜かない

公式ドキュメントに a11y の章は無いため、**公式仕様に裏打ちされた範囲**と**私の実務判断**を分けて書きます。

公式仕様に基づくもの。`Img` は `alt` を props に持つので、装飾画像でない限り内容を説明する alt を必ず書く。`lang` は `Html` と `Body` の両方に付ける（タグが剥がされる問題への対処が、そのまま言語判定の担保にもなる）。`toPlainText` は既定のセレクタとして **`img` をスキップ**し、**`a` はリンク文字列と URL が同じなら URL を重複表示しない**ように整形します。HTML には出したいがテキスト版から外したい要素には **`data-skip-in-text="true"`** を付けられます（HTML 側からは除外されません）。

ここからは実務判断です。**コントラスト**は Web と同じ基準で確保してください。メールクライアントは背景色を上書きすることがあるため、文字色だけ指定して背景色を省くと反転時に読めなくなります。**リンクテキスト**は「こちら」ではなく「領収書を表示する」のように、リンク単体で目的が分かる文言にしてください——プレーンテキスト版では URL が併記されるだけなので、文言が曖昧だと文脈が完全に失われます。そして、**プレーンテキスト版はスクリーンリーダーにとっての保険**です。HTML パートの読み上げ順に自信が持てないなら、テキストパートの品質を上げるほうが投資効率は高いです。

---

## テスト：何をどこで検証するか

| レイヤー | 手段 | 検証できること |
|---|---|---|
| 描画 | `email dev` のプレビュー | 見た目 + Linter / Compatibility / Spam の3パネル |
| 描画 | `render` のスナップショット | props 変更で HTML が意図せず壊れていないか |
| 送信 | Playwright（公式ガイド） | ルートハンドラから送信までの経路 |
| 送信 | テスト用アドレス | 配信・バウンス・苦情・抑制の各イベント |
| テンプレート | ダッシュボードの Test emails | 変数値を入れた描画結果を自分の受信箱で確認 |

スナップショットは、React Email を選んだ最大の実利です。

```tsx
// tests/emails/order-confirmation.test.tsx（JSX を含むので拡張子は .tsx）
import { expect, it } from "vitest";
import { render, toPlainText } from "react-email";
import OrderConfirmation from "@/emails/order-confirmation";

it("props を本文に反映し、CTA がリンクとして出力される", async () => {
  const html = await render(
    <OrderConfirmation customerName="友田" productName="Aegis" receiptUrl="https://example.com/r/1" />,
  );
  // 厳密一致で固定するとレイアウトの微修正で毎回落ちる。
  // 落ちてほしいのは「差し込みとリンクが消えたとき」だけ。
  expect(html).toContain("友田");
  expect(html).toContain('href="https://example.com/r/1"');
  expect(toPlainText(html)).toContain("領収書を表示する"); // 読み上げの保険が機能しているか
});
```

Playwright については Resend が公式ガイドを出しており、2つの戦略が明示されています。「Resend API を実際に呼ぶと全経路をテストできるが、**アカウントの送信枠を消費する**」「モックすれば Resend API を呼ばずにアプリ側のフローだけ検証できる」。どちらでも「**テスト用アドレスを使い、到達率に影響を与えないこと**」が公式の注意です。

実送信を伴う検証では `delivered@` / `bounced@` / `complained@` / `suppressed@resend.dev` の4つのテスト用アドレスを使います。プラスラベリングの可否・送信枠の消費・`@example.com` 宛が弾かれる挙動といった細かい制約は[冪等性・リトライ設計の記事](/blog/resend-idempotency-retry-error-handling-reliability-guide)にまとめてあります。

最後に正直な限界を書きます。**Templates 側には、プログラマティックなテスト手段が公式には用意されていません**。「変数が正しく差し込まれたか」をコードで検証する API も、テンプレート専用のエラーコードもありません（返るのは汎用の `validation_error` / `missing_required_field` / `invalid_parameter`）。プレビューサーバの Linter / Compatibility / Spam も UI としてのみ提供されています。**Templates を選ぶ＝自動テストの一部を人間のレビューに戻す判断**だと理解して採用してください。ルートハンドラ側の実装は[App Router での実装記事](/blog/resend-nextjs-app-router-route-handler-react-email-guide)にまとめています。

---

## 本番投入前チェックリスト

- [ ] `react-email` からの import に統一（`@react-email/components` / `@react-email/render` が残っていない）
- [ ] `renderAsync` を使っていない。`render` を **await** している
- [ ] `Tailwind` に `pixelBasedPreset` を渡している
- [ ] `lang` / `dir` を `Html` と `Body` の**両方**に付けた。`Preview` は90文字以内
- [ ] 画像は png / gif / jpg で、src は**本番の絶対 URL**。すべての `Img` に意味のある `alt` がある
- [ ] `text`（プレーンテキスト版）を必ず送っている。空文字で無効化していない
- [ ] CTA が `<a>` として機能し、リンク文言が単体で意味を成す
- [ ] Templates を使うなら **publish 済み**。`has_unpublished_versions` を CI で見ている
- [ ] 変数に予約語（`FIRST_NAME` / `LAST_NAME` / `EMAIL` / `UNSUBSCRIBE_URL` 系 / `contact` / `this`）が無い
- [ ] 変数値が文字列2,000文字・数値 2^53-1 の範囲に収まる
- [ ] `template` と `html` / `react` / `text` を同時に渡していない
- [ ] テンプレート CRUD に使う API キーが **Full access**
- [ ] 本文サイズが 102 KB を大きく下回っている
- [ ] 送信テストは `delivered@` / `bounced@` などのテストアドレスで行った

---

## まとめ：置き場所の選択が、変更コストを決める

メールテンプレートの設計で本当に選んでいるのは、HTML の書き方ではなく**「文面が変わるときに誰が動くか」**です。

- **React Email** は型・差分レビュー・スナップショットテストという開発者の武器がそのまま効く。代わりに文言修正にデプロイが要る。
- **Resend Templates** は draft と published の分離により**本番稼働中のテンプレートを安全に書き換えられる**。代わりに自動テストの一部を人間のレビューに戻すことになる。
- **両方を混ぜる**選択肢もある。骨格は React Email で作り、CLI かプレビューサーバの Resend タブでアップロードして、運用は非エンジニアに渡す。

どの道を選んでも、**プレーンテキスト版を必ず持つ**・**画像が落ちても壊れない**・**CTA がリンクとして機能する**の3点は共通の最低ラインです。到達率とアクセシビリティの両方に同時に効く、投資効率のいい項目です。まずは自分のプロダクトの送信メールを1通、生成 HTML とプレーンテキストの両方で読み直すところから始めてください。

> この記事は [Resend 公式ドキュメント](https://resend.com/docs)（Templates / Template Variables / Version History / Send Email）と [React Email 公式ドキュメント](https://react.email/docs)（render / components / Tailwind / CLI、いずれも2026年8月時点）、およびインストール済み `resend@6.4.1` の型定義に基づき、実運用の判断軸を加えて再構成したものです。仕様・上限値は更新されるため、本番採用時は各公式ページで最新値をご確認ください。公式ドキュメント内で記載が割れている箇所（変数の個数上限、予約語の表記）は、その旨を明記しています。
