
Resend + React Email: 예쁜 트랜잭션 이메일 보내기
회원가입 확인 이메일을 보내야 하는데 HTML 이메일이 지옥이었다. React Email로 컴포넌트처럼 이메일을 만들고 Resend로 보내는 방법을 정리했다.

회원가입 확인 이메일을 보내야 하는데 HTML 이메일이 지옥이었다. React Email로 컴포넌트처럼 이메일을 만들고 Resend로 보내는 방법을 정리했다.
회원가입 버튼을 눌렀는데 메일이 안 옵니다. 스팸함에도 없습니다. Supabase 무료 SMTP의 한계와 Resend를 연동하여 이메일 전송 성공률을 99%로 높이는 방법을 정리해봤습니다.

SaaS에 결제를 붙이려고 Stripe을 연동했다. 결제 코드는 다른 코드와 차원이 다른 긴장감이 있었다. Checkout Session부터 Webhook까지 실전 경험.

REST API를 만들 때마다 프론트엔드와 백엔드의 타입이 어긋났다. tRPC를 도입하고 API 명세서 없이 타입이 자동으로 맞춰지는 경험을 했다.

서비스를 운영하면서 유저가 어디서 이탈하는지, 어떤 기능을 쓰는지 전혀 몰랐다. PostHog를 붙이고 데이터 기반으로 결정하기 시작한 경험.

사이드 프로젝트에 회원가입 기능을 붙이면서 확인 이메일이 필요해졌다. 별거 아닐 것 같았다. "이메일 하나 보내는 건데 뭐 어렵겠어?" 싶었다. 그게 착각이었다.
처음엔 nodemailer로 HTML 문자열을 직접 짜기 시작했다. 헤더, 로고, 버튼, 안내 문구. 코드를 쓰면서도 뭔가 이상하다는 느낌이 왔다. <table> 안에 <table>을 넣고, style 속성에 인라인 CSS를 잔뜩 박아 넣고, font-family를 세 번씩 반복 선언하고 있었다.
Gmail에서 테스트했더니 그럭저럭 봐줄 만했다. Outlook에서 열었더니 레이아웃이 완전히 무너져 있었다. Apple Mail에서 열었더니 또 달랐다. 다크 모드에서 열면 배경색이 이상하게 뒤집혔다.
이게 2003년 웹 개발이다. Flexbox도 없고, CSS Grid도 없고, 변수도 없고, 컴포넌트도 없다. 오로지 <table>과 인라인 스타일, 그리고 "각 이메일 클라이언트가 제멋대로 렌더링하는 현실"에 맞서 싸우는 것이다.
이틀을 버린 뒤, 제대로 된 도구를 찾기로 했다. 그렇게 React Email과 Resend를 만났다.
왜 이메일 HTML이 이렇게 고통스러운지 이해하려면, 이메일 클라이언트가 어떻게 동작하는지 알아야 한다.
웹 브라우저는 오랜 시간 동안 표준화됐다. Chrome, Firefox, Safari 모두 HTML5와 CSS3 스펙을 거의 동일하게 구현한다. 덕분에 현대 웹 개발자는 display: flex를 쓰고 대부분의 브라우저에서 동일하게 동작하리라 기대할 수 있다.
이메일 클라이언트는 다르다. Outlook은 HTML을 렌더링할 때 브라우저 엔진을 쓰지 않는다. Microsoft Word의 렌더링 엔진을 사용한다. 진짜다. Outlook 2007부터 2019까지 Word 렌더러를 써왔다. 그래서 border-radius, background-image, Flexbox, CSS Grid, 심지어 margin: auto도 제대로 동작하지 않는다.
이걸 비유하면 이렇다. 현대 웹 개발은 고속도로를 달리는 것이다. 표준화된 규격 위에서 원하는 곳으로 빠르게 갈 수 있다. HTML 이메일 개발은 각 나라마다 다른 도로법이 적용되는 도로를 달리는 것이다. Gmail 구역에서 잘 달리다가 Outlook 구역에 들어서면 갑자기 좌측통행을 해야 하고, Apple Mail 구역에서는 오토바이만 허용된다.
| 이메일 클라이언트 | CSS 지원 수준 |
|---|---|
| Gmail (웹) | 기본 CSS, <style> 태그 지원 제한적 |
| Outlook 2016–2021 | Word 렌더러 기반, Flexbox/Grid 미지원 |
| Apple Mail | 웹킷 기반, CSS 지원 양호 |
| Samsung Mail | 구형 웹킷, 지원 불규칙 |
| Yahoo Mail | 클래스명 프리픽싱, 일부 스타일 제거 |
그래서 HTML 이메일 개발의 전통적인 해법은 이렇다. 모든 레이아웃을 <table>로 만들고, 모든 스타일을 style="" 인라인으로 박고, 최소한의 CSS만 사용하고, 각 클라이언트에서 직접 테스트한다.
이건 2024년에 2003년 방식으로 코딩하는 것이다. 그리고 그걸 자동화해주는 도구가 React Email이다.
React Email은 React 컴포넌트로 이메일 템플릿을 만드는 도구다. 핵심은 두 가지다.
<Html>, <Body>, <Section>, <Button> 같은 이메일 특화 컴포넌트를 제공한다.개발 중에는 email.react.email 또는 로컬 프리뷰 서버에서 브라우저로 바로 확인할 수 있다. 코드를 바꾸면 실시간으로 이메일이 어떻게 보이는지 확인된다. 이건 혁명이다.
설치는 간단하다.
npm install @react-email/components react-email
그리고 package.json에 프리뷰 스크립트를 추가한다.
{
"scripts": {
"email": "email dev --dir src/emails"
}
}
npm run email을 실행하면 localhost:3000에서 이메일 프리뷰 서버가 뜬다.
회원가입 확인 이메일을 React 컴포넌트로 만들면 이렇게 된다.
// src/emails/welcome.tsx
import {
Html,
Head,
Body,
Container,
Section,
Heading,
Text,
Button,
Hr,
Link,
Preview,
Tailwind,
} from "@react-email/components";
interface WelcomeEmailProps {
username: string;
confirmationUrl: string;
}
export default function WelcomeEmail({
username,
confirmationUrl,
}: WelcomeEmailProps) {
return (
<Html lang="ko">
<Head />
<Preview>{username}님, 이메일 주소를 확인해주세요</Preview>
<Tailwind>
<Body className="bg-gray-100 font-sans">
<Container className="mx-auto py-8 px-4 max-w-[560px]">
{/* 헤더 */}
<Section className="bg-white rounded-t-lg px-8 pt-8 pb-4 text-center">
<Heading className="text-2xl font-bold text-gray-900 mt-0">
환영합니다, {username}님!
</Heading>
<Text className="text-gray-600 text-base">
가입해주셔서 감사합니다. 아래 버튼을 눌러 이메일 주소를
확인해주세요.
</Text>
</Section>
{/* CTA 버튼 */}
<Section className="bg-white px-8 py-6 text-center">
<Button
href={confirmationUrl}
className="bg-black text-white rounded-md px-6 py-3 text-base font-semibold no-underline"
>
이메일 확인하기
</Button>
</Section>
<Hr className="border-gray-200 mx-8" />
{/* 푸터 */}
<Section className="bg-white rounded-b-lg px-8 pb-8 pt-4">
<Text className="text-gray-500 text-sm text-center">
이 링크는 24시간 동안 유효합니다. 버튼이 작동하지 않으면{" "}
<Link
href={confirmationUrl}
className="text-black underline"
>
여기를 클릭
</Link>
하거나 아래 URL을 복사해서 브라우저에 붙여넣으세요.
</Text>
<Text className="text-gray-400 text-xs text-center break-all">
{confirmationUrl}
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
<Tailwind> 컴포넌트가 핵심이다. Tailwind 클래스를 쓰면 렌더링할 때 이메일 클라이언트 호환 인라인 CSS로 자동 변환된다. Flexbox나 Grid 같이 지원되지 않는 속성은 자동으로 테이블 기반 레이아웃으로 대체된다.
<Preview> 컴포넌트는 이메일 클라이언트의 받은 편지함 목록에서 제목 아래 보이는 미리보기 텍스트를 설정한다. Gmail에서 이메일을 열기 전에 보이는 그 한 줄이다. 있으면 없을 때보다 오픈율이 확연히 다르다.
Resend는 개발자를 위해 만들어진 이메일 전송 API 서비스다. 창업자인 Zeno Rocha가 직접 이메일 개발의 고통을 경험하고 만들었다. React Email 오픈소스 프로젝트와 Resend가 같은 팀에서 나온 이유다.
무료 플랜은 하루 100개, 한 달 3,000개 이메일까지 지원한다. 사이드 프로젝트 초기에는 충분하다.
API 키를 발급받고 Next.js API 라우트에 연결한다.
// src/app/api/auth/confirm-email/route.ts
import { Resend } from "resend";
import { render } from "@react-email/components";
import WelcomeEmail from "@/emails/welcome";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: Request) {
const { username, email, confirmationUrl } = await request.json();
if (!username || !email || !confirmationUrl) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
try {
const emailHtml = await render(
<WelcomeEmail username={username} confirmationUrl={confirmationUrl} />
);
const { data, error } = await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: [email],
subject: `${username}님, 이메일 주소를 확인해주세요`,
html: emailHtml,
});
if (error) {
console.error("Resend error:", error);
return Response.json({ error: "Failed to send email" }, { status: 500 });
}
return Response.json({ success: true, id: data?.id });
} catch (err) {
console.error("Unexpected error:", err);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
render() 함수가 React 컴포넌트를 이메일 호환 HTML 문자열로 변환한다. 그걸 resend.emails.send()에 넘기면 끝이다. 직접 HTML을 짤 때와 비교하면 코드가 압도적으로 깔끔하다.
.env.local에 API 키를 추가한다.
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxx
이메일 서비스를 찾다 보면 선택지가 여럿 나온다. 인디 개발자 관점에서 정리해봤다.
| 서비스 | 무료 플랜 | DX | 설정 난이도 | 특징 |
|---|---|---|---|---|
| Resend | 하루 100개, 월 3,000개 | 매우 좋음 | 낮음 | React Email 네이티브 지원 |
| SendGrid | 하루 100개 | 보통 | 중간 | 마케팅 이메일 포함 |
| AWS SES | 월 62,000개 (EC2 기준) | 복잡 | 높음 | 대용량, 저비용 |
| Postmark | 없음 (유료) | 좋음 | 낮음 | 트랜잭션 특화, 고신뢰도 |
| Mailgun | 5일 무료 트라이얼 | 보통 | 중간 | 개발자 친화적 |
Resend를 고른 이유는 단순하다. React Email과 공식 통합이 돼 있고, API가 직관적이고, 문서가 훌륭하고, 무료 플랜으로 시작할 수 있다. 대규모 발송이 필요하다면 AWS SES가 훨씬 저렴하지만, 초기 설정이 복잡하고 DX가 떨어진다.
AWS SES를 비유하면 공장형 대형 인쇄소다. 단가가 낮고 대량 처리가 가능하지만, 주문서 작성, 계약, 납기 관리까지 직접 해야 한다. Resend는 동네 인쇄소다. 파일 갖다 주면 알아서 예쁘게 뽑아준다. 처음엔 이게 맞다.
이메일을 보내는 것과 받은 편지함에 도착하는 것은 다른 문제다. 아무리 예쁜 이메일을 만들어도 스팸함에 들어가면 의미가 없다.
발송 가능성(Deliverability)을 높이는 세 가지 DNS 레코드가 있다.
SPF(Sender Policy Framework): 이 도메인에서 이메일을 보낼 수 있는 서버 목록을 명시한다. Resend를 사용한다면 도메인 DNS에 SPF 레코드를 추가해야 한다.
TXT @ "v=spf1 include:_spf.resend.com ~all"
DKIM(DomainKeys Identified Mail): 이메일에 디지털 서명을 추가한다. 수신 서버가 이메일이 실제로 이 도메인에서 보낸 것임을 검증할 수 있다. Resend 대시보드에서 도메인 설정 시 자동으로 생성해준다.
DMARC(Domain-based Message Authentication, Reporting and Conformance): SPF와 DKIM 검증에 실패한 이메일을 어떻게 처리할지 정책을 설정한다.
TXT _dmarc "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"
이 세 가지를 모두 설정하지 않으면 Gmail 같은 주요 이메일 서비스에서 스팸으로 처리할 가능성이 높다. Resend 대시보드에서 도메인 설정을 하면 어떤 레코드를 추가해야 하는지 안내해주니, 그걸 따라가면 된다.
비유하자면 이렇다. 이메일은 신원 확인이 필요한 우편물과 같다. SPF는 "이 우체국에서 보낸 편지입니다"라는 소인이고, DKIM은 보낸 사람의 서명이고, DMARC는 "위조된 편지가 오면 어떻게 처리하세요"라는 수령인에게 남기는 지시사항이다. 세 가지가 다 있어야 진짜 신뢰할 수 있는 편지로 인정받는다.
React Email의 프리뷰 서버가 생산성을 크게 높인다. npm run email로 서버를 띄우면 src/emails/ 디렉토리의 모든 이메일 템플릿이 목록으로 나온다. 각 템플릿을 클릭하면 실시간 미리보기가 된다. 코드를 수정하면 즉시 반영된다.
실제 전송 전에 이메일을 테스트하고 싶다면 Resend의 테스트 모드를 활용하거나, MailHog 같은 로컬 SMTP 서버를 사용할 수 있다. 실제 이메일을 소비하지 않고 전송 시뮬레이션이 가능하다.
이메일 클라이언트 간 호환성을 더 세밀하게 테스트하려면 Email on Acid나 Litmus를 쓸 수 있다. 유료 서비스지만 70개 이상의 이메일 클라이언트에서 스크린샷을 제공한다. 초기 프로젝트에서는 주요 클라이언트(Gmail, Outlook, Apple Mail)에서만 직접 테스트해도 충분하다.
HTML 이메일 개발은 2003년 수준이다. <table>, 인라인 CSS, 클라이언트마다 다른 렌더링. 직접 짜지 마라.
React Email은 이메일을 React 컴포넌트로 만든다. Tailwind를 쓸 수 있고, 프리뷰 서버로 즉시 확인 가능하고, 렌더링 시 이메일 호환 HTML로 자동 변환된다.
Resend는 개발자 친화적인 이메일 API다. render() + resend.emails.send() 두 줄이면 이메일이 나간다. 무료 플랜으로 시작할 수 있다.
SPF, DKIM, DMARC 설정은 선택이 아니다. 이 세 가지가 없으면 스팸함으로 직행한다. Resend 대시보드의 도메인 설정 가이드를 따라가면 된다.
<Preview> 컴포넌트를 챙겨라. 받은 편지함 목록의 미리보기 텍스트가 오픈율에 영향을 준다. 빼먹기 쉬운데 중요하다.
이틀 동안 HTML 이메일과 씨름하다가 React Email과 Resend로 넘어갔다. 그다음 날 아침에 예쁜 확인 이메일이 작동하고 있었다. 진작 찾았어야 했다.
I added a signup confirmation email to my side project. How hard could it be? You just send an HTML email, right?
I started writing raw HTML email strings with nodemailer. Headers, logos, buttons, body copy. As I typed, something felt wrong. I was nesting <table> inside <table>, jamming inline CSS into every style="" attribute, repeating font-family three times in three different places.
Tested in Gmail: passable. Opened in Outlook: layout completely broken. Apple Mail: different again. Dark mode: background colors inverted in bizarre ways.
This is web development from 2003. No Flexbox, no CSS Grid, no variables, no components. Just <table> tags and inline styles and the grim reality that every email client renders HTML however it sees fit.
After two days of wasted effort, I went looking for real tools. That's how I found React Email and Resend.
To understand why email HTML is so painful, you need to understand how email clients work.
Web browsers have converged on standards over decades. Chrome, Firefox, and Safari all implement HTML5 and CSS3 near-identically. A developer can write display: flex and trust it will work everywhere.
Email clients haven't converged. Outlook doesn't use a browser engine to render HTML. It uses the Microsoft Word rendering engine. Seriously. From Outlook 2007 through 2019, Word's renderer was in charge of displaying HTML emails. That means border-radius, background-image, Flexbox, CSS Grid, and even margin: auto don't work reliably.
Here's an analogy. Modern web development is driving on a highway. Standardized rules, you go where you want. HTML email development is driving on roads where traffic laws change at every county border. Works fine in Gmail territory, then you cross into Outlook territory and suddenly you need to drive on the wrong side. Apple Mail county only allows motorcycles.
| Email Client | CSS Support |
|---|---|
| Gmail (web) | Limited <style> tag support, basic CSS |
| Outlook 2016–2021 | Word renderer, no Flexbox or Grid |
| Apple Mail | WebKit-based, reasonably good |
| Samsung Mail | Older WebKit, inconsistent |
| Yahoo Mail | Strips some styles, prefixes class names |
The traditional workaround: build all layouts with <table>, write all styles inline, use only the most basic CSS, and test manually in each client. In 2024, you code like it's 2003.
React Email automates exactly this.
React Email lets you write email templates as React components. It works in two parts:
<Html>, <Body>, <Section>, and <Button>.During development, a local preview server shows you exactly how your email will look in a browser, updating in real time as you edit code. This is the revolution: instant visual feedback, not "send to yourself and open in six clients."
Install:
npm install @react-email/components react-email
Add a preview script to package.json:
{
"scripts": {
"email": "email dev --dir src/emails"
}
}
Run npm run email and a preview server starts at localhost:3000.
Here's what a signup confirmation email looks like as a React component:
// src/emails/welcome.tsx
import {
Html,
Head,
Body,
Container,
Section,
Heading,
Text,
Button,
Hr,
Link,
Preview,
Tailwind,
} from "@react-email/components";
interface WelcomeEmailProps {
username: string;
confirmationUrl: string;
}
export default function WelcomeEmail({
username,
confirmationUrl,
}: WelcomeEmailProps) {
return (
<Html lang="en">
<Head />
<Preview>Please confirm your email address, {username}</Preview>
<Tailwind>
<Body className="bg-gray-100 font-sans">
<Container className="mx-auto py-8 px-4 max-w-[560px]">
<Section className="bg-white rounded-t-lg px-8 pt-8 pb-4 text-center">
<Heading className="text-2xl font-bold text-gray-900 mt-0">
Welcome, {username}!
</Heading>
<Text className="text-gray-600 text-base">
Thanks for signing up. Click the button below to confirm your
email address.
</Text>
</Section>
<Section className="bg-white px-8 py-6 text-center">
<Button
href={confirmationUrl}
className="bg-black text-white rounded-md px-6 py-3 text-base font-semibold no-underline"
>
Confirm Email
</Button>
</Section>
<Hr className="border-gray-200 mx-8" />
<Section className="bg-white rounded-b-lg px-8 pb-8 pt-4">
<Text className="text-gray-500 text-sm text-center">
This link expires in 24 hours. If the button doesn't work,{" "}
<Link href={confirmationUrl} className="text-black underline">
click here
</Link>{" "}
or paste the URL below into your browser.
</Text>
<Text className="text-gray-400 text-xs text-center break-all">
{confirmationUrl}
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
The <Tailwind> component is the key. Tailwind classes get converted to email-compatible inline CSS at render time. Properties that email clients don't support — Flexbox, Grid — are replaced with table-based equivalents automatically.
The <Preview> component sets the preview text visible in inbox list views, that one line you see under the subject before opening an email. Including it noticeably improves open rates. Easy to forget. Matters a lot.
Resend is an email API built explicitly for developers. The founder, Zeno Rocha, experienced the same HTML email pain firsthand. That's why the React Email open-source project and Resend come from the same team.
The free tier covers 100 emails per day, 3,000 per month. More than enough to start.
Here's a Next.js API route wiring it all together:
// src/app/api/auth/confirm-email/route.ts
import { Resend } from "resend";
import { render } from "@react-email/components";
import WelcomeEmail from "@/emails/welcome";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: Request) {
const { username, email, confirmationUrl } = await request.json();
if (!username || !email || !confirmationUrl) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
try {
const emailHtml = await render(
<WelcomeEmail username={username} confirmationUrl={confirmationUrl} />
);
const { data, error } = await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: [email],
subject: `Please confirm your email, ${username}`,
html: emailHtml,
});
if (error) {
console.error("Resend error:", error);
return Response.json({ error: "Failed to send email" }, { status: 500 });
}
return Response.json({ success: true, id: data?.id });
} catch (err) {
console.error("Unexpected error:", err);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
render() converts the React component to an email-compatible HTML string. That string goes into resend.emails.send(). Two steps. Done.
When you look into email services, several options come up. Here's how they compare from an indie developer perspective:
| Service | Free Tier | DX | Setup Complexity | Notes |
|---|---|---|---|---|
| Resend | 100/day, 3,000/month | Excellent | Low | Native React Email support |
| SendGrid | 100/day | Average | Medium | Includes marketing email tooling |
| AWS SES | 62,000/month (from EC2) | Complex | High | High volume, low cost |
| Postmark | None (paid only) | Good | Low | Transactional-focused, high deliverability |
| Mailgun | 5-day free trial | Average | Medium | Developer-friendly |
I chose Resend because it integrates natively with React Email, the API is intuitive, the documentation is excellent, and I can start for free.
AWS SES is the right answer at scale — it's dramatically cheaper per email — but the setup is complex and the developer experience is rough. It's like the difference between a high-volume industrial print shop and a quick-print shop around the corner. The industrial shop is cheaper per page, but you're handling the paperwork, the file specs, the pickup scheduling. The corner shop takes your file and hands you finished prints. Start with the corner shop.
Sending an email and having it arrive in the inbox are two different problems. A beautiful email that lands in spam is useless.
Three DNS records make or break your deliverability:
SPF (Sender Policy Framework): Declares which servers are authorized to send email from your domain.
TXT @ "v=spf1 include:_spf.resend.com ~all"
DKIM (DomainKeys Identified Mail): Adds a cryptographic signature to outgoing emails. Receiving servers can verify the email genuinely came from your domain. Resend generates the required DNS records for you in the domain settings dashboard.
DMARC (Domain-based Message Authentication, Reporting and Conformance): Tells receiving servers what to do with emails that fail SPF or DKIM verification.
TXT _dmarc "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"
Without all three, major email services like Gmail are likely to flag your emails as spam. Resend's domain setup flow walks you through exactly which records to add. Follow it before sending to real users.
Think of it like identity verification for physical mail. SPF is the official postmark proving it came from a legitimate post office. DKIM is the sender's signature. DMARC is the instruction you leave for recipients: "If you receive something forged with my name, here's what to do with it." All three together make your mail trustworthy.