Sending emails from a web application shouldn't require a PhD in SMTP configuration. Resend is a modern email API that makes transactional emails straightforward - especially for Next.js projects. This guide covers the setup, common patterns, and the gotchas you'll hit along the way.
Why Resend?
I've used SendGrid, Mailgun, and AWS SES on various projects. They all work, but they share the same problem: the developer experience feels like an afterthought. Dashboard navigation is confusing, documentation assumes you already know email infrastructure, and simple tasks require digging through settings pages.
Resend takes a different approach:
- Simple API: One function call to send an email
- Developer-first dashboard: Logs, analytics, and domain setup in obvious places
- React Email integration: Build email templates as React components
- Generous free tier: 3,000 emails/month, 100/day on free plan
- Instant setup: API key and you're sending emails
For a contact form or booking confirmation in a Next.js app, Resend gets you from zero to sending emails in about 10 minutes.
Environment setup
You need one required variable and two optional ones:
# Required
RESEND_API_KEY=re_xxxxxxxxxxxxx
# Optional - defaults work for testing
RESEND_FROM_EMAIL=noreply@yourdomain.com
CONTACT_RECIPIENT_EMAIL=hello@yourdomain.comGet your API key from the Resend dashboard (resend.com/api-keys). During development, you can send from onboarding@resend.dev - Resend's test domain - but production emails need a verified domain.
The Resend client
Create a singleton client to avoid re-initializing on every request:
// lib/resend.ts
import { Resend } from 'resend';
let resendClient: Resend | null = null;
export function getResendClient(): Resend {
if (!resendClient) {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
throw new Error('RESEND_API_KEY environment variable is required.');
}
resendClient = new Resend(apiKey);
}
return resendClient;
}
export function getResendFromEmail(): string {
return process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev';
}
export function getContactRecipientEmail(): string {
return process.env.CONTACT_RECIPIENT_EMAIL || 'your-email@example.com';
}The singleton pattern (let resendClient: Resend | null = null) ensures we reuse the same client across requests in serverless environments. Without it, you'd create a new Resend instance on every API call - not a huge problem, but wasteful.
The fallback to onboarding@resend.dev is useful for development. Resend lets anyone send from this domain for testing, so you don't need to verify your domain just to try things out.
Sending a basic email
Here's a contact form API route:
// app/api/contact/route.ts
import { NextResponse } from 'next/server';
import { getResendClient, getResendFromEmail, getContactRecipientEmail } from '@/lib/resend';
interface ContactFormData {
name: string;
email: string;
phone: string;
reason: string[];
message: string;
}
export async function POST(request: Request) {
try {
const body = await request.json() as ContactFormData;
const resend = getResendClient();
const { data, error } = await resend.emails.send({
from: getResendFromEmail(),
to: getContactRecipientEmail(),
subject: `New Contact Form Submission from ${body.name}`,
html: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${body.name}</p>
<p><strong>Email:</strong> ${body.email}</p>
<p><strong>Phone:</strong> ${body.phone}</p>
<p><strong>Reason for Contact:</strong> ${body.reason.join(', ')}</p>
<p><strong>Message:</strong></p>
<p>${body.message.replace(/\n/g, '<br>')}</p>
<hr>
<p style="color: #666; font-size: 12px;">Sent via contact form</p>
`,
});
if (error) {
console.error('Resend error:', error);
return NextResponse.json({ error: 'Failed to send email.' }, { status: 500 });
}
console.log('Email sent:', data?.id);
return NextResponse.json({ message: 'Message sent successfully!' }, { status: 201 });
} catch (error) {
console.error('Error processing contact form:', error);
return NextResponse.json({ error: 'An error occurred.' }, { status: 500 });
}
}The resend.emails.send() call returns { data, error } - similar to Supabase's pattern. Check for errors explicitly rather than relying on exceptions.
The response object
On success, data contains:
{
"id": "email_id_here"
}On failure, error contains:
{
"statusCode": 422,
"message": "The 'to' address is invalid",
"name": "validation_error"
}Log both for debugging. The email ID is useful for tracking down delivery issues in Resend's dashboard.
HTML email templates
Inline HTML works for simple emails, but it gets messy fast. A few patterns that help:
Template functions
// lib/email-templates.ts
interface ContactEmailProps {
name: string;
email: string;
phone: string;
message: string;
}
export function contactFormEmail({ name, email, phone, message }: ContactEmailProps): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">New Contact Form Submission</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Name:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${escapeHtml(name)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Email:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${escapeHtml(email)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Phone:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${escapeHtml(phone)}</td>
</tr>
</table>
<h3 style="color: #333; margin-top: 20px;">Message:</h3>
<p style="background: #f9f9f9; padding: 15px; border-radius: 4px;">
${escapeHtml(message).replace(/\n/g, '<br>')}
</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px;">
Sent via your website contact form
</p>
</body>
</html>
`;
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, char => map[char]);
}Always escape user input. Email clients render HTML, and you don't want someone injecting scripts through your contact form.
React Email (optional)
If you have multiple email templates, consider React Email. It lets you build templates as React components:
// emails/ContactFormEmail.tsx
import { Html, Body, Container, Text, Heading } from '@react-email/components';
interface Props {
name: string;
email: string;
message: string;
}
export function ContactFormEmail({ name, email, message }: Props) {
return (
<Html>
<Body style={{ fontFamily: 'sans-serif' }}>
<Container>
<Heading>New Contact Form Submission</Heading>
<Text><strong>Name:</strong> {name}</Text>
<Text><strong>Email:</strong> {email}</Text>
<Text><strong>Message:</strong> {message}</Text>
</Container>
</Body>
</Html>
);
}Then render it when sending:
import { render } from '@react-email/render';
import { ContactFormEmail } from '@/emails/ContactFormEmail';
const html = render(ContactFormEmail({ name, email, message }));
await resend.emails.send({
from: getResendFromEmail(),
to: recipient,
subject: 'New Contact',
html: html,
});For a single contact form, this is overkill. But if you're sending booking confirmations, password resets, and weekly digests, React Email keeps templates maintainable.
Domain verification
During development, send from onboarding@resend.dev. For production, you need to verify your domain.
In Resend dashboard:
- Go to Domains > Add Domain
- Enter your domain (e.g.,
yourdomain.com) - Add the DNS records Resend provides (usually 3 TXT records)
- Wait for verification (usually a few minutes, sometimes up to 48 hours)
The records look like:
resend._domainkey.yourdomain.com TXT "v=DKIM1; k=rsa; p=..."Once verified, you can send from any address at that domain: noreply@yourdomain.com, hello@yourdomain.com, etc.
Common DNS issues
"Domain not verified" after adding records: DNS propagation can take time. Wait an hour and check again.
Records added but verification fails: Make sure you're adding the records to the correct domain. If your app is at app.yourdomain.com, you still verify the root domain yourdomain.com.
Using a subdomain: You can verify mail.yourdomain.com if you prefer. Add records to that subdomain specifically.
Error handling patterns
Resend can fail for various reasons. Handle them gracefully:
export async function sendEmail(to: string, subject: string, html: string) {
try {
const resend = getResendClient();
const { data, error } = await resend.emails.send({
from: getResendFromEmail(),
to,
subject,
html,
});
if (error) {
// Log the full error for debugging
console.error('Resend API error:', {
statusCode: error.statusCode,
message: error.message,
name: error.name,
});
// Return user-friendly message based on error type
if (error.name === 'validation_error') {
return { success: false, error: 'Invalid email address.' };
}
if (error.name === 'rate_limit_exceeded') {
return { success: false, error: 'Too many requests. Please try again later.' };
}
return { success: false, error: 'Failed to send email.' };
}
return { success: true, emailId: data?.id };
} catch (err) {
// Network errors, missing API key, etc.
console.error('Unexpected error sending email:', err);
return { success: false, error: 'Email service unavailable.' };
}
}Rate limits
Resend's free tier allows 100 emails/day. The paid tier starts at 5,000/month. If you're building a contact form, you'll probably never hit these limits. But if you're sending automated emails (booking confirmations, reminders), monitor your usage.
Rate limit errors return:
{
"name": "rate_limit_exceeded",
"message": "Rate limit exceeded"
}Add exponential backoff for critical emails:
async function sendWithRetry(emailData: EmailData, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const result = await sendEmail(emailData);
if (result.success || result.error !== 'rate_limit_exceeded') {
return result;
}
// Wait before retrying: 1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
}
return { success: false, error: 'Failed after multiple attempts.' };
}Monitoring and debugging
Resend's dashboard shows every email sent: delivery status, opens (if tracking enabled), and bounces. Use it when debugging:
-
Email not arriving? Check the dashboard for delivery status. "Delivered" means it left Resend successfully - check spam folders.
-
Bounced emails: Usually means invalid recipient address. Log these and notify users to update their email.
-
Complaint: Someone marked your email as spam. If this happens frequently, review your sending practices.
Log the email ID returned from successful sends:
console.log(`Email sent to ${to}: ${data?.id}`);Then search for that ID in Resend's dashboard to see exactly what happened.
Complete example: booking confirmation
Putting it together - a booking confirmation email:
// lib/email-templates.ts
export function bookingConfirmationEmail({
customerName,
serviceName,
dateTime,
duration,
}: {
customerName: string;
serviceName: string;
dateTime: string;
duration: string;
}): string {
return `
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #333;">Booking Confirmed!</h1>
<p>Hi ${escapeHtml(customerName)},</p>
<p>Your appointment has been confirmed:</p>
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;"><strong>Service:</strong> ${escapeHtml(serviceName)}</p>
<p style="margin: 10px 0 0;"><strong>Date & Time:</strong> ${escapeHtml(dateTime)}</p>
<p style="margin: 10px 0 0;"><strong>Duration:</strong> ${escapeHtml(duration)}</p>
</div>
<p>If you need to reschedule or cancel, please contact us at least 24 hours in advance.</p>
<p>See you soon!</p>
</body>
</html>
`;
}// actions/bookingActions.ts
import { getResendClient, getResendFromEmail } from '@/lib/resend';
import { bookingConfirmationEmail } from '@/lib/email-templates';
export async function sendBookingConfirmation(booking: {
customerEmail: string;
customerName: string;
serviceName: string;
dateTime: Date;
durationMinutes: number;
}) {
const resend = getResendClient();
const { error } = await resend.emails.send({
from: getResendFromEmail(),
to: booking.customerEmail,
subject: `Booking Confirmed: ${booking.serviceName}`,
html: bookingConfirmationEmail({
customerName: booking.customerName,
serviceName: booking.serviceName,
dateTime: booking.dateTime.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}),
duration: `${booking.durationMinutes} minutes`,
}),
});
if (error) {
console.error('Failed to send booking confirmation:', error);
// Don't throw - booking succeeded even if email failed
return { emailSent: false };
}
return { emailSent: true };
}The booking confirmation doesn't throw if the email fails. The booking itself succeeded - the email is secondary. Log the failure and handle it separately (maybe queue for retry, or show a message to staff).
File structure
lib/
├── resend.ts # Client singleton + config helpers
└── email-templates.ts # HTML template functions
app/
└── api/
└── contact/
└── route.ts # Contact form endpoint
emails/ # Optional: React Email templates
├── ContactFormEmail.tsx
└── BookingConfirmation.tsxKey takeaways
- Use a singleton pattern for the Resend client
- Send from
onboarding@resend.devduring development- Verify your domain for production emails
- Always escape user input in HTML templates
- Check
errorin the response, don't just try-catch- Log email IDs for debugging delivery issues
- Don't let email failures break your main flow
Resend does one thing well: sending transactional emails with minimal setup. For a Next.js app that needs contact forms, booking confirmations, or password resets, it's hard to beat the simplicity.


