メールの HTML は、2026年の Web の HTML とは別の言語です。Flexbox も Grid も動くクライアントと動かないクライアントがあり、rem すら安全ではありません。そして厄介なことに、メール本文は「一度書いて終わり」にならない。価格が変わり、文言が変わり、法務が一文足せと言い、英語版が要る。
この記事が解くのは、その変更をどこで受け止めるかという設計の問題です。本文を TypeScript のコードとして持つのか、Resend のダッシュボードに置いて非エンジニアに渡すのか。選択を誤ると「文言をひとつ直すのに本番デプロイが必要」か、逆に「誰がいつ何を変えたのか誰も追えない」のどちらかに落ちます。
私はこのポートフォリオサイト自体を Resend で本番運用しています(問い合わせ・リードマグネット配布・7通のメール講座・Stripe 決済後の納品メール)。現時点の実装は後述する「インライン HTML 文字列」方式で、その利点と限界の両方を実地で味わっています。以下は Resend / React Email の公式ドキュメント(2026年8月時点)と、インストール済み resend@6.4.1 の型定義に忠実な内容に、その運用判断を重ねたものです。全体像はResend 本番運用ガイドにまとめています。
メール本文の置き場所は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 とプレーンテキストを必ずペアで返す純粋関数になっています。
// 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 をインライン化 | 要素にのみインライン化される |
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 の変更点は公開情報から確認できないため、この記事ではそれらのバージョン固有の挙動には触れません。
最小のテンプレート
// 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 は別関数
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通りと、私が後者を推す理由
// 方法A:SDK に描画させる(公式の推奨形)
await resend.emails.send({ from, to, subject, react: <OrderConfirmation productName="Aegis" /> });
公式は「他サービスと連携するときは React テンプレートを HTML に変換する必要があるが、Resend はそれを代行する」と書いています。ただし本番では、私は次を推します。
// 方法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
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 です。
変数記法は三重波括弧
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個を超える変数が要るなら、それはテンプレートではなくコードで組むべき本文です。正確な最新値は公式の該当ページで確認してください。
draft と published は別物
ここが Templates の設計の核心で、事故を防ぐ仕組みでもあります。
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語をコードレビューで意識的に区別してください。
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 し忘れた下書きが本番に残っている」ことを機械的に検知できる唯一の信号です。
/** 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 するのでテンプレート数が多いとレート制限に触れます(冪等性・リトライ設計の記事で扱っています)。
送信時:template は html / react / text と排他
ここは resend@6.4.1 の型定義がそのまま仕様です。CreateEmailOptions は「本文系(react / html / text のいずれか必須)」か「template」かの判別可能ユニオンになっていて、両方渡すとコンパイルが通りません。API も検証エラーを返します。
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 の両方でサポートされます(一括配信は一括送信・予約送信・購読管理の記事へ)。
React Email から Templates へ持っていく
コードで骨格を作り、運用は非エンジニアに渡す中間解も用意されています。
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 はそのままでは貼れないという制約自体は現実として扱ってください。
どちらを選ぶか
文面を直すのは誰か?
├─ 開発者だけ
│ ├─ 本文がロジックを持つ(条件分岐・繰り返し・計算)
│ │ └─▶ 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)はドメイン認証と到達率の記事に分けています。
アクセシビリティ:メールでも手を抜かない
公式ドキュメントに 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 を選んだ最大の実利です。
// 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 宛が弾かれる挙動といった細かい制約は冪等性・リトライ設計の記事にまとめてあります。
最後に正直な限界を書きます。Templates 側には、プログラマティックなテスト手段が公式には用意されていません。「変数が正しく差し込まれたか」をコードで検証する API も、テンプレート専用のエラーコードもありません(返るのは汎用の validation_error / missing_required_field / invalid_parameter)。プレビューサーバの Linter / Compatibility / Spam も UI としてのみ提供されています。Templates を選ぶ=自動テストの一部を人間のレビューに戻す判断だと理解して採用してください。ルートハンドラ側の実装はApp Router での実装記事にまとめています。
本番投入前チェックリスト
-
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 公式ドキュメント(Templates / Template Variables / Version History / Send Email)と React Email 公式ドキュメント(render / components / Tailwind / CLI、いずれも2026年8月時点)、およびインストール済み
resend@6.4.1の型定義に基づき、実運用の判断軸を加えて再構成したものです。仕様・上限値は更新されるため、本番採用時は各公式ページで最新値をご確認ください。公式ドキュメント内で記載が割れている箇所(変数の個数上限、予約語の表記)は、その旨を明記しています。