Transactional Emails That Don't Land in Spam — React Email + Resend
Transactional emails from new SaaS products often land in spam. Here's how to build type-safe email templates with React Email and deliver them reliably with Resend.
40% of transactional emails from new SaaS products land in spam. Not marketing emails — transactional ones. Password resets. Magic links. Invoices. The emails your users are actively waiting for.
I learned this the hard way. My first SaaS had a password reset flow that worked perfectly in development. In production, Gmail silently routed half the reset emails to spam. Users thought the feature was broken. Support tickets piled up. Some users just left.
The problem wasn't my code. It was everything around it: raw HTML templates that looked suspicious to spam filters, a misconfigured sending domain, and zero email authentication records.
When I built OmniKit, I made email infrastructure a first-class concern. Here's the setup — React Email for templates, Resend for delivery — and the deliverability practices that keep emails out of spam.
Key Takeaways
- React Email lets you build email templates as React components — type-safe, composable, and locally previewable
- Resend handles delivery with built-in analytics, retry logic, and excellent deliverability
- SPF, DKIM, and DMARC are non-negotiable — Gmail and Yahoo now reject unauthenticated emails outright
- Separate your sending subdomain from your main domain to protect your brand reputation
- Preview locally before sending —
pnpm email:devgives you hot-reload email development
Why React Email Over Raw HTML
Email HTML is a nightmare. No flexbox. No grid. Limited CSS support. Every client renders differently. Outlook still uses Word's rendering engine. In 2026.
The traditional approach looks like this:
// The old way - raw HTML strings
await sendEmail({
to: user.email,
subject: "Reset your password",
html: `
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0">
<tr>
<td style="font-family: Arial, sans-serif; font-size: 16px; color: #333;">
<h1 style="margin: 0 0 20px 0;">Reset Your Password</h1>
<p>Hi ${user.name},</p>
<p>Click <a href="${resetUrl}" style="color: #2563eb;">here</a> to reset.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
`,
})No type safety. No reusability. No way to preview without actually sending. One typo in a style attribute and half your users see broken formatting.
React Email fixes all of this. You write components:
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components"
interface ResetPasswordEmailProps {
userName: string
resetUrl: string
expiresIn: string
}
export default function ResetPasswordEmail({
userName,
resetUrl,
expiresIn,
}: ResetPasswordEmailProps) {
return (
<Html>
<Head />
<Preview>Reset your password for OmniKit</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Reset Your Password</Heading>
<Text style={text}>Hi {userName},</Text>
<Text style={text}>
Someone requested a password reset for your account.
This link expires in {expiresIn}.
</Text>
<Section style={buttonContainer}>
<Button style={button} href={resetUrl}>
Reset Password
</Button>
</Section>
<Text style={footer}>
If you didn't request this, ignore this email.
Your password won't change.
</Text>
</Container>
</Body>
</Html>
)
}
const main = {
backgroundColor: "#f6f9fc",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
}
const container = {
backgroundColor: "#ffffff",
margin: "0 auto",
padding: "40px 20px",
maxWidth: "560px",
borderRadius: "8px",
}
const h1 = { fontSize: "24px", fontWeight: "600", margin: "0 0 24px" }
const text = { fontSize: "16px", lineHeight: "1.6", color: "#374151" }
const buttonContainer = { textAlign: "center" as const, margin: "32px 0" }
const button = {
backgroundColor: "#2563eb",
color: "#ffffff",
padding: "12px 24px",
borderRadius: "6px",
fontSize: "16px",
fontWeight: "600",
textDecoration: "none",
}
const footer = { fontSize: "14px", color: "#9ca3af", marginTop: "32px" }TypeScript catches missing props at build time. The component renders to cross-client-compatible HTML automatically. And you can preview it locally with hot reload by running pnpm email:dev.
This is the same principle behind type-safe environment variables — catch errors at build time, not in production.
The Email Templates You Actually Need
Every SaaS needs the same core set of transactional emails. After building multiple products, here's the list I always start with:
Authentication Emails
These are triggered by your authentication module. OmniKit uses Better Auth, which calls your email service automatically for these flows:
// emails/magic-link.tsx
import {
Body, Button, Container, Head, Html, Preview, Text
} from "@react-email/components"
interface MagicLinkEmailProps {
url: string
userName?: string
}
export default function MagicLinkEmail({ url, userName }: MagicLinkEmailProps) {
return (
<Html>
<Head />
<Preview>Your sign-in link for OmniKit</Preview>
<Body style={main}>
<Container style={container}>
<Text style={heading}>Sign in to OmniKit</Text>
<Text style={text}>
Hi {userName || "there"}, click below to sign in.
This link expires in 10 minutes.
</Text>
<Button style={button} href={url}>
Sign In
</Button>
<Text style={footer}>
If you didn't request this, you can safely ignore it.
</Text>
</Container>
</Body>
</Html>
)
}You'll need the same pattern for email verification (sent on registration) and password reset (sent on recovery request). The component structure is identical — only the copy and the action URL change.
Subscription Lifecycle Emails
These fire from Stripe webhook handlers:
| Trigger | Why It Matters | |
|---|---|---|
| Welcome | Subscription created | First impression, onboarding |
| Trial ending | 3 days before expiry | Conversion nudge |
| Payment failed | Charge fails | Retention — users churn over expired cards |
| Cancellation | User cancels | Win-back opportunity |
Here's a trial ending email:
// emails/trial-ending.tsx
import {
Body, Button, Container, Head, Html, Preview, Section, Text
} from "@react-email/components"
interface TrialEndingEmailProps {
userName: string
daysLeft: number
upgradeUrl: string
}
export default function TrialEndingEmail({
userName,
daysLeft,
upgradeUrl,
}: TrialEndingEmailProps) {
return (
<Html>
<Head />
<Preview>Your trial ends in {daysLeft} days</Preview>
<Body style={main}>
<Container style={container}>
<Text style={heading}>
Your trial ends in {daysLeft} {daysLeft === 1 ? "day" : "days"}
</Text>
<Text style={text}>Hi {userName},</Text>
<Text style={text}>
Just a heads up — your free trial is ending soon.
Upgrade now to keep access to all features.
</Text>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button style={button} href={upgradeUrl}>
Upgrade Now
</Button>
</Section>
<Text style={footerText}>
Questions? Reply to this email — I read every one.
</Text>
</Container>
</Body>
</Html>
)
}Shared Components
Once you have three or four templates, you'll notice duplication. Extract shared pieces:
// emails/components/email-footer.tsx
import { Hr, Link, Text } from "@react-email/components"
export function EmailFooter() {
return (
<>
<Hr style={{ borderColor: "#e5e7eb", margin: "32px 0" }} />
<Text style={{ fontSize: "12px", color: "#9ca3af", lineHeight: "1.6" }}>
OmniKit, Inc. ·{" "}
<Link href="https://omnikit.dev" style={{ color: "#9ca3af" }}>
omnikit.dev
</Link>
</Text>
</>
)
}// emails/components/email-button.tsx
import { Button } from "@react-email/components"
interface EmailButtonProps {
href: string
children: React.ReactNode
}
export function EmailButton({ href, children }: EmailButtonProps) {
return (
<Button
href={href}
style={{
backgroundColor: "#2563eb",
color: "#ffffff",
padding: "12px 24px",
borderRadius: "6px",
fontSize: "16px",
fontWeight: "600",
textDecoration: "none",
}}
>
{children}
</Button>
)
}Now every template uses <EmailButton> and <EmailFooter>. Brand change? Update two files. Done.
Integrating Resend
Resend is the delivery layer. You write React Email components, Resend turns them into delivered emails with retry logic, bounce handling, and analytics.
Setup is minimal:
pnpm add resend @react-email/componentsThe email service module:
// lib/email.ts
import { Resend } from "resend"
if (!process.env.RESEND_API_KEY) {
console.warn("RESEND_API_KEY is not set - emails will be skipped")
}
export const resend = process.env.RESEND_API_KEY
? new Resend(process.env.RESEND_API_KEY)
: nullThe guard clause is intentional. In development, you might not have a Resend key. The app still runs — emails just get skipped with a console log. No crashes. This pattern mirrors how we handle env var validation across the entire stack.
Sending an email:
import { resend } from "@/lib/email"
import ResetPasswordEmail from "@/emails/reset-password"
export async function sendPasswordReset(
to: string,
userName: string,
resetUrl: string
) {
if (!resend) {
console.log("Skipping email - Resend not configured")
return
}
await resend.emails.send({
from: "OmniKit <noreply@notifications.omnikit.dev>",
to,
subject: "Reset your password",
react: ResetPasswordEmail({ userName, resetUrl, expiresIn: "1 hour" }),
})
}Notice the react property — Resend natively accepts React Email components. No manual renderToStaticMarkup step. It handles the rendering internally.
Local Preview
React Email ships with a dev server. Add this to your package.json:
{
"scripts": {
"email:dev": "email dev --dir emails"
}
}Run pnpm email:dev and you get a browser-based preview at localhost:3000 with hot reload. Change a component, save, see the update instantly. You can test different prop values, check responsive behavior, and verify that your styles work before sending a single email.
This preview loop alone saves hours of the old "send test email, check Gmail, tweak HTML, repeat" cycle.
Email Deliverability: The DNS Records That Matter
Building nice templates is the easy part. Getting them delivered is where most SaaS founders stumble.
In 2026, Gmail and Yahoo actively reject unauthenticated emails at the SMTP level. Not "send to spam" — reject. Your email never reaches any folder. The user never knows you sent anything.
Three DNS records prevent this.
SPF (Sender Policy Framework)
SPF tells receiving mail servers which servers are authorized to send email for your domain.
Type: TXT
Host: @
Value: v=spf1 include:resend.com ~allWithout SPF, any server can claim to send email from your domain. Gmail sees that and treats your email like phishing.
DKIM (DomainKeys Identified Mail)
DKIM attaches a cryptographic signature to every email. The receiving server verifies the signature against a public key in your DNS. If the signature matches, the email hasn't been tampered with in transit.
Resend generates your DKIM keys automatically. You add the provided DNS records:
Type: CNAME
Host: resend._domainkey
Value: (provided by Resend during domain verification)Use 2048-bit keys minimum. Rotate them periodically — once a year is reasonable.
DMARC (Domain-based Message Authentication, Reporting & Conformance)
DMARC ties SPF and DKIM together and tells receiving servers what to do when authentication fails. Start with a monitoring-only policy:
Type: TXT
Host: _dmarc
Value: v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.comThe rollout sequence matters:
p=none— Monitor only. Collect reports to see who's sending email as your domainp=quarantine— Failed emails go to spam. Do this after a few weeks of clean reportsp=reject— Failed emails are blocked entirely. The final goal
Never jump straight to p=reject. You'll block legitimate email from services you forgot about.
Use a Sending Subdomain
This is the tip that saves SaaS founders the most headaches. Send from a subdomain like notifications.yourdomain.com instead of your root domain.
Why? If something goes wrong — a spike in bounces, a spam complaint — the reputation damage hits notifications.yourdomain.com, not yourdomain.com. Your website, your main email, your other services stay clean.
In OmniKit, we send from notifications.omnikit.dev:
from: "OmniKit <noreply@notifications.omnikit.dev>"This is standard practice. Companies like GitHub (noreply@github.com), Stripe (receipts@stripe.com), and Vercel (ship@vercel.com) all use dedicated sending subdomains.
Deliverability Checklist
Beyond DNS records, here's what keeps your emails out of spam:
Keep spam complaints under 0.1%. Google's official threshold is 0.3%, but you want margin. If 1,000 people receive your email and 3 click "Report Spam," you're at the limit. Transactional emails rarely trigger complaints — unless you're sending too frequently or to addresses that didn't expect them.
Don't send from noreply@ without a reply-to. Receiving servers score emails higher when there's a valid reply path. Even if you use noreply@ as the from address, set a replyTo field:
await resend.emails.send({
from: "OmniKit <noreply@notifications.omnikit.dev>",
replyTo: "support@omnikit.dev",
to,
subject: "Your invoice",
react: InvoiceEmail({ ... }),
})Include an unsubscribe mechanism for recurring emails. Password resets don't need one. But trial reminders and usage alerts do. Gmail checks for this.
Warm up your domain gradually. Don't send 10,000 emails on day one from a brand new domain. Start with tens, then hundreds. Let your domain build reputation over a few weeks.
Monitor with Resend's dashboard. Track delivery rates, open rates, and bounce rates. A sudden drop in delivery rate is an early warning sign. Resend provides these analytics out of the box — no extra setup.
These aren't theoretical concerns. I've seen SaaS products where the actual cost of debugging email issues exceeded the cost of building the feature in the first place. Getting it right from the start saves real time and money.
The Architecture in OmniKit
Here's how the pieces fit together:
emails/
├── magic-link.tsx # Passwordless auth
├── verify-email.tsx # Email verification
├── reset-password.tsx # Password recovery
├── components/
│ ├── footer.tsx # Shared footer
│ ├── header.tsx # Shared header
│ └── button.tsx # Shared CTA button
lib/
└── email.ts # Resend client + send helpersAuth emails (magic link, verification, password reset) are triggered automatically by Better Auth. You customize the templates. Subscription lifecycle emails fire from Stripe webhook handlers. You own the logic.
The separation is clean: React Email handles presentation, Resend handles delivery, and your application code handles when to send what.
Every email template is a React component with TypeScript props. Every sending function validates its inputs. The security practices that protect your app's API endpoints also apply to your email system — validate inputs, sanitize data, never trust user content in email templates without escaping.
Common Mistakes to Avoid
Sending emails synchronously in API routes. Email delivery can take 1-3 seconds. If you await the send inside a user-facing request, your API feels slow. Use background jobs or fire-and-forget patterns for non-critical emails.
Using your root domain as the sender. One bad day ruins your entire domain's reputation. Always use a subdomain.
Skipping email preview during development. "It works in Gmail" doesn't mean it works in Outlook. React Email's local preview catches layout issues before they reach users.
Hardcoding email content. Use props and shared components. When your brand colors change, you want to update one file, not twelve.
Ignoring bounces. A high bounce rate tanks your sender reputation. Remove invalid addresses after hard bounces. Resend handles soft bounce retries automatically, but you need to handle hard bounces in your application.
Wrapping Up
Transactional email isn't glamorous infrastructure. Nobody signs up for your SaaS because of your beautiful password reset email. But when that email lands in spam, users leave. When it arrives instantly with clear formatting and a working button, they trust your product just a little more.
React Email gives you a sane developer experience for building templates. Resend gives you reliable delivery without managing SMTP servers. SPF, DKIM, and DMARC give you the authentication that Gmail and Yahoo now require.
OmniKit ships with all of this pre-configured — templates, delivery service, DNS guidance, and preview tooling. You customize the copy, add your brand colors, and move on to the features that actually differentiate your product.
Questions about email setup or deliverability? Reach out at raman@omnikit.dev or ask in our Discord community.
Read more
Adding AI Features to Your SaaS in 30 Minutes - A Practical Guide
Add AI features to your SaaS fast. Pre-configured API routes for OpenAI, Anthropic, and Google with credit-based usage tracking and streaming responses.
Build an Admin Panel with Feature Flags and Audit Logs in Next.js
How OmniKit Pro ships a production-ready admin panel with user management, feature flags, audit logging, and per-plan limits — so you never build one from scratch.
Stripe Webhooks in Next.js — The Complete Guide
Stripe webhooks in Next.js: signature verification, handling checkout.session.completed, subscription events, idempotency, and local testing with Stripe CLI.