Claude Code with Resend: Transactional Email That Delivers
Why Resend without CLAUDE.md sends emails that land in spam
Resend is the cleanest email API available to Node.js and TypeScript developers in 2026. The SDK is small, the types are accurate, and a basic send is five lines. The problem is that Claude Code does not know which of those five lines are non-negotiable for deliverability and which are optional conveniences. Without explicit constraints, Claude generates code that works in the sense that Resend accepts the request, but produces emails that spam filters reject, inbox providers flag as suspicious, and marketing analytics cannot track.
The most common Claude defaults that hurt deliverability: omitting the text: field and sending HTML-only (many spam filters penalise this heavily), using a sender address on an unverified domain (Resend will reject it or fall back to a sandbox address), hardcoding the API key instead of reading from an environment variable, and ignoring the { data, error } tuple that resend.emails.send() returns. On top of those, Claude makes a consistent SDK-level mistake: it writes replyTo in camelCase when the Resend API accepts reply_to in snake_case. The TypeScript SDK wraps this internally and accepts both, but Claude working directly with the API or with older SDK versions generates the wrong key.
This guide covers the CLAUDE.md configuration that locks Claude Code into Resend's correct model: a verified sender domain, a plain-text fallback alongside every HTML email, explicit tuple handling, and the domain authentication records that signal trustworthiness to every inbox provider. If you are building a Next.js application and need the broader backend context, Claude Code with Next.js covers the request lifecycle that your email sends will sit inside. For payment confirmation emails specifically, Claude Code with Stripe shows how to trigger Resend sends from webhook handlers.
The Resend CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Resend integration it needs to declare: the SDK version, the environment variable name for the API key, the mandatory fields on every send call, the response tuple handling pattern, the sender domain policy, and the hard rules that block the mistakes Claude makes most often.
# Resend email rules
## Stack
- resend ^3.x, TypeScript 5.x strict
- React Email ^2.x for HTML templates
- Node.js 20.x (or Next.js 14.x API routes / route handlers)
- RESEND_API_KEY in .env.local (never hardcode)
## Project structure
- src/lib/resend.ts , Resend client singleton
- src/emails/ , React Email components (*.tsx)
- src/app/api/email/ , Next.js route handlers that call sendEmail()
- src/lib/send-email.ts , Shared wrapper with error handling
## Client initialisation
- ALWAYS use a singleton: import { resend } from '@/lib/resend'
- src/lib/resend.ts content:
import { Resend } from 'resend';
export const resend = new Resend(process.env.RESEND_API_KEY);
- NEVER instantiate new Resend() inline in route handlers or server actions
## Send pattern (MANDATORY fields)
Every resend.emails.send() call MUST include ALL of these:
- from: 'Team Name <team@yourdomain.com>' (verified domain, no sandbox)
- to: string | string[] (recipient)
- subject: string (non-empty)
- html: string (rendered HTML)
- text: string (plain-text fallback, REQUIRED for deliverability)
Optional but commonly needed:
- reply_to: string | string[] (snake_case, NOT replyTo)
- cc: string | string[]
- bcc: string | string[]
- attachments: [{ filename, content }]
- tags: [{ name, value }] (for analytics segmentation)
## Response handling (MANDATORY)
resend.emails.send() returns { data, error } - ALWAYS handle both:
const { data, error } = await resend.emails.send({ ... });
if (error) {
console.error('Resend error:', error);
throw new Error(error.message);
}
// data.id is the message ID for tracking
NEVER discard the return value. NEVER assume success without checking error.
## Hard rules
- NEVER hardcode RESEND_API_KEY in source files
- NEVER omit text: (HTML-only emails are penalised by spam filters)
- NEVER use reply_to in camelCase (replyTo) with the raw API, use snake_case
- NEVER send from an address on an unverified domain in production
- NEVER send from the Resend onboarding sandbox (onboarding@resend.dev) in production
- NEVER swallow the error from the { data, error } tuple
- ALWAYS check that RESEND_API_KEY is defined at startup, not at send time
Three rules here prevent the majority of production failures Claude generates without them.
The mandatory text: rule is the most impactful for deliverability. HTML emails without a plain-text alternative trigger spam filter heuristics on Gmail, Outlook, and most corporate mail gateways. The plain-text version does not need to be beautiful. It needs to exist. Claude omits it because the Resend SDK does not require it at the type level, so no TypeScript error surfaces the omission.
The reply_to snake_case rule prevents a subtle runtime bug. The Resend TypeScript SDK (v3.x) accepts replyTo in camelCase internally and converts it. But if Claude generates code that passes parameters directly to the raw Resend REST API, or if an older SDK version is installed, replyTo is silently dropped and the reply address is lost. Declaring the snake_case form in CLAUDE.md makes Claude generate the correct key in all contexts.
The tuple handling rule prevents silent failures. Claude often generates await resend.emails.send({ ... }) and uses the result directly without destructuring. If the send fails, the error is swallowed and the application continues as if the email was sent. The pattern const { data, error } = await resend.emails.send({ ... }) makes the error path explicit.
Install and API key setup
Install the Resend SDK:
npm i resend
Add the API key to your environment file. For Next.js:
# .env.local
RESEND_API_KEY=re_your_api_key_here
Create the singleton client that every send in the project imports from:
// src/lib/resend.ts
import { Resend } from 'resend';
if (!process.env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY is not defined');
}
export const resend = new Resend(process.env.RESEND_API_KEY);
The startup check (if (!process.env.RESEND_API_KEY)) surfaces a missing key immediately when the server boots rather than at the moment of the first send, which could be hours into a production deployment. Claude omits this check by default because it is not required by the SDK. Add it to your CLAUDE.md singleton example and Claude will generate it consistently.
Add the singleton pattern to CLAUDE.md so Claude never instantiates a new client inline:
## Client singleton (ENFORCE)
- The only Resend client in the project lives at src/lib/resend.ts
- Every file that sends email does: import { resend } from '@/lib/resend'
- Claude MUST NOT write: const resend = new Resend(process.env.RESEND_API_KEY) inside route handlers
The send() pattern
The core send call with all mandatory fields:
// src/lib/send-email.ts
import { resend } from '@/lib/resend';
interface SendEmailOptions {
to: string | string[];
subject: string;
html: string;
text: string;
replyTo?: string | string[];
}
export async function sendEmail(options: SendEmailOptions) {
const { data, error } = await resend.emails.send({
from: 'Team Claudify <hello@claudify.tech>',
to: options.to,
subject: options.subject,
html: options.html,
text: options.text,
reply_to: options.replyTo,
});
if (error) {
console.error('[Resend] Send failed:', error);
throw new Error(`Email delivery failed: ${error.message}`);
}
return data;
}
Calling this from a Next.js route handler:
// src/app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendEmail } from '@/lib/send-email';
export async function POST(req: NextRequest) {
const { name, email, message } = await req.json();
await sendEmail({
to: 'support@claudify.tech',
subject: `Contact form: ${name}`,
html: `<p><strong>${name}</strong> (${email}) says:</p><p>${message}</p>`,
text: `${name} (${email}) says:\n\n${message}`,
replyTo: email,
});
return NextResponse.json({ ok: true });
}
Add the response handling pattern to CLAUDE.md as a concrete example. Claude learns from concrete patterns faster than abstract rules. Showing the full const { data, error } = await resend.emails.send({ ... }) block once in the CLAUDE.md template produces correct handling across every generated send call.
React Email integration
React Email lets you write email templates as typed JSX components and render them to an HTML string at send time. The result is maintainable, version-controlled email templates instead of raw HTML strings concatenated in route handlers.
Install the React Email renderer:
npm i @react-email/render @react-email/components
A simple transactional email component:
// src/emails/WelcomeEmail.tsx
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Link,
Preview,
Section,
Text,
} from '@react-email/components';
interface WelcomeEmailProps {
userName: string;
loginUrl: string;
}
export function WelcomeEmail({ userName, loginUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to Claudify, {userName}</Preview>
<Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f9f9f9' }}>
<Container style={{ maxWidth: '600px', margin: '0 auto', padding: '40px 20px' }}>
<Heading style={{ fontSize: '24px', color: '#111' }}>
Welcome, {userName}
</Heading>
<Text style={{ color: '#444', lineHeight: '1.6' }}>
Your account is ready. Click the link below to log in.
</Text>
<Section>
<Link href={loginUrl} style={{ color: '#000', fontWeight: '600' }}>
Log in to Claudify
</Link>
</Section>
<Hr />
<Text style={{ color: '#999', fontSize: '12px' }}>
Claudify, hello@claudify.tech
</Text>
</Container>
</Body>
</Html>
);
}
Rendering and sending:
// src/lib/send-welcome.ts
import { render } from '@react-email/render';
import { WelcomeEmail } from '@/emails/WelcomeEmail';
import { sendEmail } from '@/lib/send-email';
export async function sendWelcomeEmail(userName: string, userEmail: string) {
const html = await render(<WelcomeEmail userName={userName} loginUrl="https://claudify.tech/login" />);
// Generate a plain-text version from the component props
const text = [
`Welcome, ${userName}`,
'',
'Your account is ready. Click the link below to log in.',
'',
'https://claudify.tech/login',
'',
'Claudify, hello@claudify.tech',
].join('\n');
await sendEmail({
to: userEmail,
subject: `Welcome to Claudify, ${userName}`,
html,
text,
});
}
Add a React Email section to CLAUDE.md:
## React Email integration
- Email components live in src/emails/*.tsx
- Import render from '@react-email/render'
- html field: html = await render(<ComponentName {...props} />)
- text field: construct manually from the same props, keep it readable
- NEVER skip the text field because html comes from a React component
- Preview text maps to the <Preview> component, always set it
- Test locally: npx react-email dev (port 3000 by default)
The npx react-email dev command starts a local preview server that renders your email components in the browser with realistic inbox previews. Claude will not suggest running this without the instruction in CLAUDE.md because it does not appear in the standard Resend docs.
Domain verification
Resend requires a verified sending domain in production. The verification process adds DNS records that inbox providers use to authenticate your emails. Without them, Gmail, Outlook, and other providers treat your email as unauthenticated and apply aggressive spam filtering.
The three records that matter:
SPF (Sender Policy Framework): A TXT record that authorises Resend's servers to send on behalf of your domain.
Type: TXT
Name: @ (or yourdomain.com)
Value: v=spf1 include:_spf.resend.com ~all
DKIM (DomainKeys Identified Mail): CNAME records that let Resend sign your outgoing messages with a cryptographic key. Resend generates these for you in the dashboard. They look like:
Type: CNAME
Name: resend._domainkey
Value: resend._domainkey.yourdomain.com.dkim.resend.com
DMARC (Domain-based Message Authentication, Reporting and Conformance): A TXT record that tells inbox providers what to do when SPF or DKIM fails, and where to send failure reports.
Type: TXT
Name: _dmarc
Value: v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com
Start with p=none in DMARC. This puts DMARC in monitoring mode: it reports failures but does not reject or quarantine failing messages. Once you have confirmed that SPF and DKIM are passing consistently (check the DMARC aggregate reports), move to p=quarantine and then p=reject. Moving directly to p=reject before verifying SPF and DKIM will cause your own legitimate emails to bounce.
Add a domain verification section to CLAUDE.md:
## Domain verification (REQUIRED before production sends)
### DNS records (add at your registrar or DNS host)
SPF:
Type: TXT, Name: @, Value: v=spf1 include:_spf.resend.com ~all
DKIM:
Type: CNAME, Name: resend._domainkey
Value: resend._domainkey.yourdomain.com.dkim.resend.com
(exact values in Resend dashboard under Domains)
DMARC:
Type: TXT, Name: _dmarc, Value: v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com
### Rules
- NEVER send from a domain without SPF and DKIM verified in Resend dashboard
- Start DMARC with p=none, move to p=quarantine after confirming SPF+DKIM pass
- MX record is not required for sending-only domains but is required to receive replies
- Verify propagation with: dig TXT yourdomain.com and dig CNAME resend._domainkey.yourdomain.com
Claude will not add these DNS records to project documentation unless prompted, because they are infrastructure steps rather than code. Including the record values in CLAUDE.md gives Claude the context to reference them when a developer asks about deliverability issues or domain setup. For a Supabase project where you need to send email on auth events, Claude Code with Supabase covers the auth hook pattern that triggers Resend sends.
reply_to, cc, and bcc patterns
The optional addressing fields follow the same { data, error } pattern. All three accept a single email string or an array of strings.
const { data, error } = await resend.emails.send({
from: 'hello@claudify.tech',
to: 'user@example.com',
subject: 'Your order confirmation',
html: '<p>Thanks for your order.</p>',
text: 'Thanks for your order.',
reply_to: 'support@claudify.tech', // single address
cc: ['billing@claudify.tech'], // cc one address
bcc: ['archive@claudify.tech', 'cto@claudify.tech'], // bcc multiple
});
The reply_to field is the one Claude most often gets wrong. In camelCase (replyTo) it works with the current TypeScript SDK because the SDK normalises field names internally. In snake_case (reply_to) it works in all contexts: raw API calls, the SDK, older SDK versions, and code examples Claude generates that mix patterns. The CLAUDE.md rule to use snake_case removes the ambiguity.
Add the addressing fields to CLAUDE.md with a concrete example, not just a description. Claude reproduces patterns it can see. An abstract rule like "use reply_to not replyTo" is less reliable than a code block showing the correct snake_case form next to the send call.
Attachments
Resend accepts file attachments as a base64-encoded content buffer or a path string pointing to a local file. The content form is more portable for server environments where the file is generated in memory (PDF receipts, CSV exports, generated images).
import fs from 'fs';
import path from 'path';
// Attach a buffer (generated in memory)
const pdfBuffer = await generateInvoicePdf(orderId);
const { data, error } = await resend.emails.send({
from: 'billing@claudify.tech',
to: 'customer@example.com',
subject: 'Your invoice',
html: '<p>Your invoice is attached.</p>',
text: 'Your invoice is attached.',
attachments: [
{
filename: `invoice-${orderId}.pdf`,
content: pdfBuffer, // Buffer or base64 string
},
],
});
// Attach a file from disk (development / local scripts)
const { data: data2, error: error2 } = await resend.emails.send({
from: 'team@claudify.tech',
to: 'colleague@example.com',
subject: 'Weekly report',
html: '<p>This week report is attached.</p>',
text: 'This week report is attached.',
attachments: [
{
filename: 'report.pdf',
path: path.join(process.cwd(), 'tmp', 'report.pdf'),
},
],
});
Add an attachments section to CLAUDE.md:
## Attachments
- attachments: [{ filename: string, content: Buffer | string }] for in-memory files
- attachments: [{ filename: string, path: string }] for local file system paths
- filename MUST include the extension, it appears as the download name
- content accepts a Node.js Buffer directly, no manual base64 encoding needed
- Never attach files larger than 40 MB (Resend hard limit)
- ALWAYS include subject that indicates an attachment is present
Batch sends and rate limits
Resend's batch send endpoint accepts up to 100 email objects in a single API call. This is more efficient than sending 100 individual requests and avoids hitting the per-second rate limit on the free tier.
import { resend } from '@/lib/resend';
interface BatchEmail {
to: string;
userName: string;
}
async function sendBatchWelcomeEmails(recipients: BatchEmail[]) {
if (recipients.length > 100) {
throw new Error('Batch size exceeds Resend limit of 100');
}
const emails = recipients.map(({ to, userName }) => ({
from: 'hello@claudify.tech',
to,
subject: `Welcome to Claudify, ${userName}`,
html: `<p>Welcome, <strong>${userName}</strong>. Your account is ready.</p>`,
text: `Welcome, ${userName}. Your account is ready.`,
}));
const { data, error } = await resend.batch.send(emails);
if (error) {
console.error('[Resend batch] Error:', error);
throw new Error(`Batch send failed: ${error.message}`);
}
console.log(`[Resend batch] Sent ${data?.data?.length ?? 0} emails`);
return data;
}
// For lists larger than 100, chunk the array
async function sendToLargeList(recipients: BatchEmail[]) {
const CHUNK_SIZE = 100;
for (let i = 0; i < recipients.length; i += CHUNK_SIZE) {
const chunk = recipients.slice(i, i + CHUNK_SIZE);
await sendBatchWelcomeEmails(chunk);
// Pause between chunks to stay within rate limits
if (i + CHUNK_SIZE < recipients.length) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
}
Rate limits on Resend's plans (approximate, as of 2026):
| Plan | Rate limit | Batch size |
|---|---|---|
| Free | 10 req/sec | 100 emails |
| Pro | Higher (see dashboard) | 100 emails |
| Enterprise | Custom | 100 emails |
Add a batch and rate limit section to CLAUDE.md:
## Batch sends
- resend.batch.send([email1, email2, ...]) for up to 100 emails per call
- { data, error } tuple applies, always handle error
- For lists over 100: chunk into 100-item arrays, add 200ms pause between chunks
- NEVER loop resend.emails.send() for large lists, use resend.batch.send()
- Free tier: 10 req/sec maximum, plan accordingly
## Rate limit handling
- 429 responses from Resend mean rate limit exceeded
- Retry with exponential backoff: 1s, 2s, 4s, max 3 retries
- Log rate limit hits, they indicate volume planning is needed
Webhooks for delivery events
Resend fires webhooks when email delivery events occur: message sent to the provider, delivered to the inbox, bounced, or marked as spam. Subscribing to these events lets you update application state based on real delivery outcomes rather than assuming success.
The four events to subscribe to:
| Event | When it fires |
|---|---|
email.sent |
Resend accepted the message and sent it to the receiving server |
email.delivered |
The receiving server confirmed delivery |
email.bounced |
Permanent delivery failure (invalid address, domain not found) |
email.complained |
Recipient marked as spam |
Set up the webhook endpoint in your Next.js app:
// src/app/api/webhooks/resend/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Webhook } from 'svix';
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET;
export async function POST(req: NextRequest) {
if (!webhookSecret) {
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
}
const payload = await req.text();
const headers = {
'svix-id': req.headers.get('svix-id') ?? '',
'svix-timestamp': req.headers.get('svix-timestamp') ?? '',
'svix-signature': req.headers.get('svix-signature') ?? '',
};
let event: { type: string; data: Record<string, unknown> };
try {
const wh = new Webhook(webhookSecret);
event = wh.verify(payload, headers) as typeof event;
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
switch (event.type) {
case 'email.delivered':
// Mark email as delivered in your database
await markEmailDelivered(event.data.email_id as string);
break;
case 'email.bounced':
// Unsubscribe or flag the recipient address
await handleBounce(event.data.to as string);
break;
case 'email.complained':
// Immediately suppress the address from all future sends
await suppressAddress(event.data.to as string);
break;
default:
break;
}
return NextResponse.json({ ok: true });
}
async function markEmailDelivered(emailId: string) {
// update your DB record
}
async function handleBounce(address: string) {
// flag the address as undeliverable
}
async function suppressAddress(address: string) {
// add to suppression list immediately
}
Install svix for signature verification:
npm i svix
Add the webhook secret to .env.local:
RESEND_WEBHOOK_SECRET=whsec_your_secret_here
Add a webhooks section to CLAUDE.md:
## Webhooks
- Endpoint: src/app/api/webhooks/resend/route.ts
- Verify signature with svix before processing
- RESEND_WEBHOOK_SECRET in .env.local
- Handle: email.sent, email.delivered, email.bounced, email.complained
- email.bounced: mark address undeliverable in DB, stop sending to it
- email.complained: suppress address immediately, no further sends
- Return 200 for all valid events (even those you do not act on)
- Return 400 only for invalid signature
- Idempotent handlers: Resend may deliver the same event more than once
The idempotency note matters. Resend's webhook delivery is at-least-once, not exactly-once. A network timeout on the initial delivery will cause Resend to retry. Your handlers need to check whether the event has already been processed before acting. A simple email_id lookup in the database is sufficient.
Common Claude Code mistakes with Resend
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Inline client instantiation
Claude generates: const resend = new Resend(process.env.RESEND_API_KEY); at the top of every file that sends email.
Correct pattern: one singleton at src/lib/resend.ts, imported everywhere.
2. Missing text: field
Claude generates: html: '<p>Welcome!</p>' with no text: field.
Correct pattern: every send call includes both html and text.
3. Swallowed error
Claude generates: await resend.emails.send({ ... }) with no handling of the return value.
Correct pattern: const { data, error } = await resend.emails.send({ ... }); if (error) throw new Error(error.message);
4. Sandbox sender in production
Claude generates: from: 'onboarding@resend.dev' copied from the quickstart docs.
Correct pattern: from: 'your-name@your-verified-domain.com'.
5. replyTo camelCase (raw API context)
Claude generates: replyTo: 'support@example.com'.
Correct pattern: reply_to: 'support@example.com'.
6. No batch for bulk sends
Claude generates: for (const user of users) { await resend.emails.send({ ... }); } for lists of any size.
Correct pattern: await resend.batch.send(users.map(user => ({ ... }))) for lists up to 100, chunked for larger lists.
Add a common mistakes section to CLAUDE.md with these six pairs. Claude benefits from explicit before/after comparisons because it can match the pattern it would otherwise generate to the corrected form.
Permission hooks for email scripts
A Resend project accumulates scripts: seed scripts that send test emails, batch scripts that email large user lists, suppression list managers, delivery report generators. Some are read-only. Some trigger real sends to real addresses. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(npx react-email dev*)",
"Bash(node scripts/preview-email.js*)",
"Bash(node scripts/check-dns.js*)",
"Bash(node scripts/generate-report.js*)"
],
"deny": [
"Bash(node scripts/send-batch.js*)",
"Bash(node scripts/email-all-users.js*)",
"Bash(node scripts/purge-suppression-list.js*)"
]
}
}
Previewing email templates and checking DNS records are safe operations with no external side effects. Sending a batch to the full user list or purging the suppression list require explicit confirmation. The deny list forces Claude to surface those operations as prompts rather than running them as part of an automated workflow.
Building email sends that reach the inbox
The Resend CLAUDE.md in this guide produces email integrations where the sender domain is verified with SPF, DKIM, and DMARC, every HTML email carries a plain-text fallback, the { data, error } tuple is always handled, the singleton client is used instead of inline instantiation, reply_to is always snake_case, and batch sends use resend.batch.send() instead of a loop.
The underlying principle is the same as any API integration with Claude Code. Resend without a CLAUDE.md produces code that looks correct and compiles cleanly but fails in ways that are hard to diagnose: emails that reach spam folders, reply addresses that disappear, errors that are swallowed silently, and bulk sends that hit rate limits and stop mid-list. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.
For the next layer up from transactional email, Resend works alongside Claude Code with Vercel for preview environment sends, and Claudify includes a Resend-specific CLAUDE.md template with the singleton pattern, React Email integration, domain verification guidance, webhook setup, and all six common-mistake rules pre-configured.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify