Resend Integration

Send transactional emails with Resend and React Email.

Dependencies#

npm install resend @react-email/components

Environment Variables#

# .env.local RESEND_API_KEY=re_... EMAIL_FROM=noreply@yourdomain.com

Client Setup#

// lib/resend.ts import { Resend } from 'resend'; export const resend = new Resend(process.env.RESEND_API_KEY);

Email Templates#

Welcome Email#

1// emails/welcome.tsx 2import { 3 Body, 4 Button, 5 Container, 6 Head, 7 Heading, 8 Html, 9 Img, 10 Link, 11 Preview, 12 Section, 13 Text, 14} from '@react-email/components'; 15 16interface WelcomeEmailProps { 17 name: string; 18 loginUrl: string; 19} 20 21export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) { 22 return ( 23 <Html> 24 <Head /> 25 <Preview>Welcome to our platform!</Preview> 26 <Body style={main}> 27 <Container style={container}> 28 <Img 29 src="https://yourdomain.com/logo.png" 30 width="40" 31 height="40" 32 alt="Logo" 33 /> 34 35 <Heading style={h1}>Welcome, {name}!</Heading> 36 37 <Text style={text}> 38 Thanks for signing up. We're excited to have you on board. 39 </Text> 40 41 <Section style={buttonContainer}> 42 <Button style={button} href={loginUrl}> 43 Get Started 44 </Button> 45 </Section> 46 47 <Text style={text}> 48 If you have any questions, just reply to this email—we're always 49 happy to help out. 50 </Text> 51 52 <Text style={footer}> 53 © 2024 Your Company. All rights reserved. 54 </Text> 55 </Container> 56 </Body> 57 </Html> 58 ); 59} 60 61const main = { 62 backgroundColor: '#f6f9fc', 63 fontFamily: 64 '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif', 65}; 66 67const container = { 68 backgroundColor: '#ffffff', 69 margin: '0 auto', 70 padding: '40px 20px', 71 maxWidth: '560px', 72}; 73 74const h1 = { 75 color: '#1a1a1a', 76 fontSize: '24px', 77 fontWeight: '600', 78 lineHeight: '40px', 79 margin: '30px 0', 80}; 81 82const text = { 83 color: '#444', 84 fontSize: '16px', 85 lineHeight: '26px', 86 margin: '16px 0', 87}; 88 89const buttonContainer = { 90 textAlign: 'center' as const, 91 margin: '32px 0', 92}; 93 94const button = { 95 backgroundColor: '#6366f1', 96 borderRadius: '8px', 97 color: '#fff', 98 fontSize: '16px', 99 fontWeight: '600', 100 textDecoration: 'none', 101 textAlign: 'center' as const, 102 display: 'inline-block', 103 padding: '12px 24px', 104}; 105 106const footer = { 107 color: '#898989', 108 fontSize: '12px', 109 marginTop: '40px', 110}; 111 112export default WelcomeEmail;

Password Reset Email#

1// emails/password-reset.tsx 2import { 3 Body, 4 Button, 5 Container, 6 Head, 7 Heading, 8 Html, 9 Preview, 10 Section, 11 Text, 12} from '@react-email/components'; 13 14interface PasswordResetProps { 15 resetUrl: string; 16 expiresIn: string; 17} 18 19export function PasswordResetEmail({ resetUrl, expiresIn }: PasswordResetProps) { 20 return ( 21 <Html> 22 <Head /> 23 <Preview>Reset your password</Preview> 24 <Body style={main}> 25 <Container style={container}> 26 <Heading style={h1}>Reset Your Password</Heading> 27 28 <Text style={text}> 29 We received a request to reset your password. Click the button 30 below to create a new password. 31 </Text> 32 33 <Section style={buttonContainer}> 34 <Button style={button} href={resetUrl}> 35 Reset Password 36 </Button> 37 </Section> 38 39 <Text style={text}> 40 This link will expire in {expiresIn}. If you didn't request this, 41 you can safely ignore this email. 42 </Text> 43 44 <Text style={footer}> 45 If the button doesn't work, copy and paste this URL into your 46 browser: {resetUrl} 47 </Text> 48 </Container> 49 </Body> 50 </Html> 51 ); 52} 53 54// ... styles same as above 55 56export default PasswordResetEmail;

Invoice Email#

1// emails/invoice.tsx 2import { 3 Body, 4 Container, 5 Head, 6 Heading, 7 Html, 8 Preview, 9 Row, 10 Column, 11 Section, 12 Text, 13} from '@react-email/components'; 14 15interface InvoiceItem { 16 description: string; 17 quantity: number; 18 price: number; 19} 20 21interface InvoiceEmailProps { 22 invoiceNumber: string; 23 customerName: string; 24 items: InvoiceItem[]; 25 total: number; 26 dueDate: string; 27} 28 29export function InvoiceEmail({ 30 invoiceNumber, 31 customerName, 32 items, 33 total, 34 dueDate, 35}: InvoiceEmailProps) { 36 return ( 37 <Html> 38 <Head /> 39 <Preview>Invoice #{invoiceNumber}</Preview> 40 <Body style={main}> 41 <Container style={container}> 42 <Heading style={h1}>Invoice #{invoiceNumber}</Heading> 43 44 <Text style={text}>Hi {customerName},</Text> 45 <Text style={text}>Here's your invoice for this billing period.</Text> 46 47 <Section style={table}> 48 <Row style={tableHeader}> 49 <Column style={tableHeaderCell}>Item</Column> 50 <Column style={tableHeaderCell}>Qty</Column> 51 <Column style={tableHeaderCell}>Price</Column> 52 </Row> 53 {items.map((item, index) => ( 54 <Row key={index} style={tableRow}> 55 <Column style={tableCell}>{item.description}</Column> 56 <Column style={tableCell}>{item.quantity}</Column> 57 <Column style={tableCell}>${item.price.toFixed(2)}</Column> 58 </Row> 59 ))} 60 <Row style={totalRow}> 61 <Column style={tableCell} colSpan={2}>Total</Column> 62 <Column style={totalCell}>${total.toFixed(2)}</Column> 63 </Row> 64 </Section> 65 66 <Text style={text}> 67 Payment is due by {dueDate}. 68 </Text> 69 </Container> 70 </Body> 71 </Html> 72 ); 73} 74 75const table = { 76 width: '100%', 77 borderCollapse: 'collapse' as const, 78 margin: '24px 0', 79}; 80 81const tableHeader = { 82 backgroundColor: '#f6f9fc', 83}; 84 85const tableHeaderCell = { 86 padding: '12px', 87 textAlign: 'left' as const, 88 fontSize: '12px', 89 fontWeight: '600', 90 color: '#666', 91 borderBottom: '1px solid #eee', 92}; 93 94const tableRow = { 95 borderBottom: '1px solid #eee', 96}; 97 98const tableCell = { 99 padding: '12px', 100 fontSize: '14px', 101}; 102 103const totalRow = { 104 backgroundColor: '#f6f9fc', 105}; 106 107const totalCell = { 108 padding: '12px', 109 fontSize: '16px', 110 fontWeight: '600', 111}; 112 113export default InvoiceEmail;

Email Service#

1// lib/email.ts 2import { resend } from '@/lib/resend'; 3import { WelcomeEmail } from '@/emails/welcome'; 4import { PasswordResetEmail } from '@/emails/password-reset'; 5import { InvoiceEmail } from '@/emails/invoice'; 6 7const FROM_EMAIL = process.env.EMAIL_FROM || 'noreply@example.com'; 8 9export async function sendWelcomeEmail(to: string, name: string) { 10 const loginUrl = `${process.env.NEXT_PUBLIC_APP_URL}/sign-in`; 11 12 const { data, error } = await resend.emails.send({ 13 from: FROM_EMAIL, 14 to, 15 subject: 'Welcome to Our Platform!', 16 react: WelcomeEmail({ name, loginUrl }), 17 }); 18 19 if (error) { 20 console.error('Failed to send welcome email:', error); 21 throw error; 22 } 23 24 return data; 25} 26 27export async function sendPasswordResetEmail( 28 to: string, 29 resetToken: string 30) { 31 const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${resetToken}`; 32 33 const { data, error } = await resend.emails.send({ 34 from: FROM_EMAIL, 35 to, 36 subject: 'Reset Your Password', 37 react: PasswordResetEmail({ resetUrl, expiresIn: '1 hour' }), 38 }); 39 40 if (error) { 41 console.error('Failed to send password reset email:', error); 42 throw error; 43 } 44 45 return data; 46} 47 48export async function sendInvoiceEmail( 49 to: string, 50 invoice: { 51 number: string; 52 customerName: string; 53 items: Array<{ description: string; quantity: number; price: number }>; 54 total: number; 55 dueDate: string; 56 } 57) { 58 const { data, error } = await resend.emails.send({ 59 from: FROM_EMAIL, 60 to, 61 subject: `Invoice #${invoice.number}`, 62 react: InvoiceEmail({ 63 invoiceNumber: invoice.number, 64 customerName: invoice.customerName, 65 items: invoice.items, 66 total: invoice.total, 67 dueDate: invoice.dueDate, 68 }), 69 }); 70 71 if (error) { 72 console.error('Failed to send invoice email:', error); 73 throw error; 74 } 75 76 return data; 77}

Batch Sending#

1// lib/email/batch.ts 2import { resend } from '@/lib/resend'; 3 4export async function sendBatchEmails( 5 emails: Array<{ 6 to: string; 7 subject: string; 8 react: React.ReactElement; 9 }> 10) { 11 const batch = emails.map((email) => ({ 12 from: process.env.EMAIL_FROM!, 13 to: email.to, 14 subject: email.subject, 15 react: email.react, 16 })); 17 18 const { data, error } = await resend.batch.send(batch); 19 20 if (error) { 21 console.error('Batch send failed:', error); 22 throw error; 23 } 24 25 return data; 26}

Email Preview Server#

1// scripts/preview-email.ts 2import { render } from '@react-email/render'; 3import { WelcomeEmail } from '@/emails/welcome'; 4import { writeFileSync } from 'fs'; 5 6async function main() { 7 const html = await render( 8 WelcomeEmail({ 9 name: 'John', 10 loginUrl: 'https://example.com/login', 11 }) 12 ); 13 14 writeFileSync('preview.html', html); 15 console.log('Preview saved to preview.html'); 16} 17 18main();

API Route for Sending#

1// app/api/email/send/route.ts 2import { auth } from '@clerk/nextjs/server'; 3import { NextRequest, NextResponse } from 'next/server'; 4import { sendWelcomeEmail } from '@/lib/email'; 5 6export async function POST(request: NextRequest) { 7 const { userId } = await auth(); 8 if (!userId) { 9 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 10 } 11 12 const { type, to, data } = await request.json(); 13 14 try { 15 switch (type) { 16 case 'welcome': 17 await sendWelcomeEmail(to, data.name); 18 break; 19 default: 20 return NextResponse.json( 21 { error: 'Unknown email type' }, 22 { status: 400 } 23 ); 24 } 25 26 return NextResponse.json({ success: true }); 27 } catch (error) { 28 console.error('Email send failed:', error); 29 return NextResponse.json( 30 { error: 'Failed to send email' }, 31 { status: 500 } 32 ); 33 } 34}

Webhooks for Tracking#

1// app/api/webhooks/resend/route.ts 2import { NextRequest, NextResponse } from 'next/server'; 3import { prisma } from '@/lib/prisma'; 4 5export async function POST(request: NextRequest) { 6 const payload = await request.json(); 7 8 switch (payload.type) { 9 case 'email.delivered': 10 await prisma.emailLog.update({ 11 where: { messageId: payload.data.email_id }, 12 data: { status: 'delivered', deliveredAt: new Date() }, 13 }); 14 break; 15 16 case 'email.opened': 17 await prisma.emailLog.update({ 18 where: { messageId: payload.data.email_id }, 19 data: { opened: true, openedAt: new Date() }, 20 }); 21 break; 22 23 case 'email.clicked': 24 await prisma.emailLog.update({ 25 where: { messageId: payload.data.email_id }, 26 data: { 27 clicked: true, 28 clickedAt: new Date(), 29 clickedLink: payload.data.link, 30 }, 31 }); 32 break; 33 34 case 'email.bounced': 35 await prisma.emailLog.update({ 36 where: { messageId: payload.data.email_id }, 37 data: { status: 'bounced', bouncedAt: new Date() }, 38 }); 39 break; 40 } 41 42 return NextResponse.json({ received: true }); 43}

Testing#

1// __tests__/emails/welcome.test.tsx 2import { render } from '@react-email/render'; 3import { WelcomeEmail } from '@/emails/welcome'; 4 5describe('WelcomeEmail', () => { 6 it('renders correctly', async () => { 7 const html = await render( 8 WelcomeEmail({ 9 name: 'John', 10 loginUrl: 'https://example.com/login', 11 }) 12 ); 13 14 expect(html).toContain('Welcome, John!'); 15 expect(html).toContain('https://example.com/login'); 16 }); 17});