Kit ships with a complete transactional email system powered by Resend and React Email. The system includes 6 pre-built templates, automatic retry with exponential backoff, database logging with status tracking, and webhook processing for delivery analytics.
This page covers setup, sending emails, templates, and webhook processing. For rate limiting configuration, see Caching & Redis. For payment-related emails, see Webhooks & Customer Portal.
How It Works
Every email flows through a three-layer pipeline — from the service function to Resend's API and back via webhooks:
Service Function (sendWelcomeEmail, sendContactConfirmation, ...)
|
|--- 1. Rate limit check (was this email recently sent?)
|--- 2. Render React Email template to HTML
|--- 3. Log attempt to database (status: PENDING)
|
v
Email Client (client.ts)
|--- Sends via Resend SDK
|--- Retries on failure (3 attempts, exponential backoff)
|--- Skips retry on validation errors (4xx)
|
v
Resend API
|--- Delivers to recipient inbox
|--- Fires webhook events (delivered, opened, clicked, bounced)
|
v
Webhook Route (/api/webhooks/resend)
|--- Verifies HMAC-SHA256 signature
|--- Updates EmailLog status in database
|--- Tracks: DELIVERED → OPENED → CLICKED or BOUNCED
Setup
1
Get a Resend API key
Create an account at resend.com and generate an API key from the API Keys page in your dashboard. The free tier includes 3,000 emails/month.
2
Configure environment variables
Add the following to your
apps/boilerplate/.env.local:bash
RESEND_API_KEY=re_your_api_key_here
RESEND_FROM_EMAIL=noreply@yourdomain.com
RESEND_WEBHOOK_SECRET=whsec_your_webhook_secret # Optional, recommended for production
3
Verify your domain
In the Resend dashboard, go to Domains and add your sending domain. Follow the DNS verification steps (add the required TXT, MX, and DKIM records). Until verified, emails are sent from Resend's shared domain.
During development, Resend allows sending to your own email address without domain verification. You only need a verified domain for sending to other recipients in production.
Sending Emails
The email service provides dedicated functions for each email type. Each function handles rate limiting, template rendering, database logging, and error handling automatically:
src/lib/email/service.ts — sendWelcomeEmail
export async function sendWelcomeEmail(
userId: string,
email: string,
name?: string
): Promise<boolean> {
try {
// Check rate limiting
const recentlySent = await wasEmailRecentlySent(
email,
EmailType.WELCOME,
60 * 24 // 24 hours
)
if (recentlySent) {
console.log(`[Email] Welcome email recently sent to ${email}, skipping`)
return false
}
// Render email template
const html = await render(
WelcomeEmail({
name: name || 'there',
email,
dashboardUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
})
)
// Log attempt
const emailLog = await logEmail({
user: { connect: { id: userId } },
to: email,
from: process.env.RESEND_FROM_EMAIL,
subject: 'Welcome to Our Platform!',
type: EmailType.WELCOME,
status: EmailStatus.PENDING,
metadata: { templateUsed: 'welcome' },
})
// Send email
const result = await sendEmail({
to: formatEmailAddress(email, name),
subject: 'Welcome to Our Platform!',
html,
})
The underlying
sendEmail function in client.ts handles retries with exponential backoff. It automatically retries on server errors (5xx) but skips retries on validation errors (4xx):src/lib/email/client.ts — sendEmail with Retry Logic
export async function sendEmail(options: EmailOptions): Promise<EmailResult> {
const {
from = DEFAULT_FROM_EMAIL,
retries = 3,
retryDelay = 1000,
...emailOptions
} = options
let lastError: Error | null = null
let attempt = 0
while (attempt < retries) {
try {
const response = await resend.emails.send({
from,
...emailOptions,
} as CreateEmailOptions)
// Check for Resend API errors
if ('error' in response && response.error) {
throw new Error(response.error.message || 'Unknown Resend error')
}
return {
success: true,
data: response as CreateEmailResponse,
}
} catch (error) {
lastError = error as Error
attempt++
// Don't retry on validation errors (4xx)
if (isValidationError(error)) {
break
}
// Wait before retrying (exponential backoff)
if (attempt < retries) {
await delay(retryDelay * Math.pow(2, attempt - 1))
}
}
}
// All retries failed
const errorMessage = lastError?.message || 'Failed to send email'
console.error('[Email] Send failed after retries:', errorMessage)
return {
success: false,
error: errorMessage,
}
}
Available Service Functions
| Function | Purpose | Rate Limit |
|---|---|---|
sendWelcomeEmail() | New user registration | 1 per 24 hours per email |
sendContactConfirmation() | Contact form submission | 1 per 5 minutes per email |
sendContactNotificationToAdmin() | Admin alert for new contact | No duplicate check |
sendSubscriptionEmail() | Subscription lifecycle events | 1 per 30 minutes per email |
sendTemplatedEmail() | DUAL pricing emails (trial, credits) | 1 per 30 minutes per email |
sendTestEmail() | Development/testing | No rate limit |
Email Templates
Kit includes 6 React Email templates built with shared components (header, footer, button):
| Template | File | Props |
|---|---|---|
| Welcome | welcome.tsx | name, email, dashboardUrl |
| Contact Confirmation | contact-confirmation.tsx | name, email, message |
| Subscription Cancelled | subscription-cancelled.tsx | name, planName, endDate |
| Trial Expired (Free) | trial-expired-free.tsx | name, freeTierFeatures, upgradeUrl |
| Trial Expired (Locked) | trial-expired-locked.tsx | name, pricingUrl |
| Bonus Credits Purchased | bonus-credits-purchased.tsx | name, credits, price, packageName, newBalance |
Here is the Welcome template as an example:
src/emails/templates/welcome.tsx
import { Heading, Section, Text } from '@react-email/components'
import { Button } from '../components/button'
import { EmailFooter } from '../components/footer'
import { EmailHeader } from '../components/header'
interface WelcomeEmailProps {
name?: string
email: string
dashboardUrl?: string
}
export default function WelcomeEmail({
name = 'there',
email,
dashboardUrl = 'https://example.com/dashboard',
}: WelcomeEmailProps) {
const preview = `Welcome to our platform, ${name}!`
return (
<EmailHeader preview={preview}>
<Section style={box}>
<Heading style={heading}>Welcome aboard! 🎉</Heading>
<Text style={paragraph}>Hi {name},</Text>
<Text style={paragraph}>
We're thrilled to have you join our community! Your account has
been successfully created with the email address:{' '}
<strong>{email}</strong>
</Text>
<Text style={paragraph}>Here's what you can do next:</Text>
<Section style={list}>
<Text style={listItem}>✓ Complete your profile</Text>
<Text style={listItem}>✓ Explore our features</Text>
<Text style={listItem}>✓ Connect with other users</Text>
<Text style={listItem}>✓ Customize your settings</Text>
</Section>
<Section style={buttonContainer}>
<Button href={dashboardUrl}>Go to Dashboard</Button>
</Section>
<Text style={paragraph}>
If you have any questions or need help getting started, our support
team is here to help. Simply reply to this email or visit our help
center.
</Text>
<Text style={paragraph}>
Best regards,
<br />
The Team
</Text>
</Section>
<EmailFooter />
</EmailHeader>
)
}
Shared Components
All templates use three shared components from
apps/boilerplate/src/emails/components/:EmailHeader— Wraps the email in a consistent layout with logo and preview textEmailFooter— Adds unsubscribe link, company address, and legal textButton— Styled CTA button that renders consistently across email clients
Creating Custom Templates
1
Create the template file
Add a new file in
apps/boilerplate/src/emails/templates/. Use the existing templates as a reference:tsx
// src/emails/templates/order-confirmation.tsx
import { Heading, Section, Text } from '@react-email/components'
import { Button } from '../components/button'
import { EmailFooter } from '../components/footer'
import { EmailHeader } from '../components/header'
interface OrderConfirmationProps {
name: string
orderId: string
amount: string
}
export default function OrderConfirmation({
name = 'Customer',
orderId,
amount,
}: OrderConfirmationProps) {
return (
<EmailHeader preview={`Order ${orderId} confirmed`}>
<Section style={{ padding: '0 48px' }}>
<Heading>Order Confirmed</Heading>
<Text>Hi {name}, your order #{orderId} for {amount} has been confirmed.</Text>
<Button href={`${process.env.NEXT_PUBLIC_APP_URL}/orders/${orderId}`}>
View Order
</Button>
</Section>
<EmailFooter />
</EmailHeader>
)
}
2
Add a service function
Add a new function in
apps/boilerplate/src/lib/email/service.ts that renders and sends the template:typescript
export async function sendOrderConfirmation(
email: string,
data: { name: string; orderId: string; amount: string }
): Promise<boolean> {
const html = await render(
OrderConfirmation({ name: data.name, orderId: data.orderId, amount: data.amount })
)
const result = await sendEmail({
to: formatEmailAddress(email, data.name),
subject: `Order ${data.orderId} Confirmed`,
html,
})
return result.success
}
3
Preview your template
Run the React Email dev server to preview templates in the browser:
bash
pnpm email:dev
This starts a local server where you can view and test all templates with live reload.
Contact Form Integration
Kit includes a complete contact form flow that sends two emails automatically:
- Confirmation to the sender —
sendContactConfirmation()sends a "We received your message" email - Notification to admin —
sendContactNotificationToAdmin()alerts the configured admin email
The admin email is determined by
NEXT_PUBLIC_CONTACT_EMAIL (falls back to RESEND_FROM_EMAIL). The contact form API route at /api/contact triggers both emails after validating and sanitizing the input.Webhook Processing
Resend sends webhook events when email status changes. Kit processes these automatically to update the
EmailLog status in the database:src/app/api/webhooks/resend/route.ts — Signature Verification
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import crypto from 'crypto'
import { processEmailWebhook } from '@/lib/email/service'
import { withRateLimit } from '@/lib/security/rate-limit-middleware'
/**
* Verify webhook signature from Resend
*/
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
try {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)
} catch (error) {
console.error('[Webhook] Signature verification error:', error)
return false
}
}
export const POST = withRateLimit('webhooks', async (request: NextRequest) => {
try {
// Get raw body for signature verification
const rawBody = await request.text()
// Get signature from headers
const headersList = await headers()
const signature = headersList.get('resend-signature')
// Verify webhook signature if secret is configured
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET
if (webhookSecret && signature) {
const isValid = verifyWebhookSignature(rawBody, signature, webhookSecret)
if (!isValid) {
console.warn('[Webhook] Invalid signature')
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
)
}
} else if (process.env.NODE_ENV === 'production') {
// In production, webhook secret should always be configured
console.error('[Webhook] No webhook secret configured')
return NextResponse.json(
{ error: 'Webhook not configured' },
{ status: 500 }
)
}
Tracked Events
| Resend Event | Database Status | Description |
|---|---|---|
email.delivered | DELIVERED | Email reached recipient's inbox |
email.opened | OPENED | Recipient opened the email |
email.clicked | CLICKED | Recipient clicked a link |
email.bounced | BOUNCED | Email bounced (invalid address) |
email.complained | FAILED | Recipient marked as spam |
To receive webhook events, add your webhook URL in the Resend dashboard under Webhooks:
https://yourdomain.com/api/webhooks/resend. Select the events you want to track and copy the signing secret to RESEND_WEBHOOK_SECRET.Kit's Resend webhook handler returns
200 OK even when processing fails internally. This is intentional — returning error status codes (4xx/5xx) causes Resend to retry the webhook, which can create retry storms for permanently invalid payloads. Errors are logged for debugging, but the response always acknowledges receipt. The same pattern is used for Clerk and Lemon Squeezy webhooks.Database Logging
Every email sent through the service is logged to the
EmailLog model with full lifecycle tracking:EmailLog
├── id # Auto-generated UUID
├── userId # Linked to User (optional for system emails)
├── to # Recipient email
├── from # Sender email
├── subject # Email subject line
├── type # WELCOME | TRANSACTION | NOTIFICATION | SYSTEM | ...
├── status # PENDING → SENT → DELIVERED → OPENED → CLICKED
├── messageId # Resend message ID (for webhook correlation)
├── metadata # JSON — template used, webhook data, error details
├── createdAt # When the email was queued
└── updatedAt # Last status change
The status progresses through a lifecycle:
PENDING → SENT → DELIVERED → OPENED → CLICKED. If delivery fails, the status moves to FAILED or BOUNCED instead.Rate Limiting
Each email type has a built-in rate limit to prevent duplicate sends. The
wasEmailRecentlySent() function checks the EmailLog table before sending:| Email Type | Window | Effect |
|---|---|---|
| Welcome | 24 hours | Prevents duplicate welcome emails on re-registration |
| Contact Confirmation | 5 minutes | Prevents contact form spam |
| Subscription Events | 30 minutes | Prevents duplicate subscription notifications |
| Templated Emails | 30 minutes | Prevents duplicate credit/trial emails |
| Test Email | None | Always sends (development only) |
Email rate limiting works at two levels: (1) per-template limits in
service.ts prevent duplicate sends, and (2) API route rate limiting via withRateLimit('email', handler) prevents abuse of the email API endpoints. See Caching & Redis for API rate limiting configuration.Environment Variables
| Variable | Required | Default | Purpose |
|---|---|---|---|
RESEND_API_KEY | Yes | test_key | Resend API key for sending emails |
RESEND_FROM_EMAIL | Yes | noreply@example.com | Default sender email address |
RESEND_WEBHOOK_SECRET | No | — | HMAC secret for webhook signature verification |
NEXT_PUBLIC_CONTACT_EMAIL | No | Falls back to RESEND_FROM_EMAIL | Admin email for contact form notifications |
NEXT_PUBLIC_APP_URL | Yes | — | Base URL for links in email templates |
Key Files
| File | Purpose |
|---|---|
apps/boilerplate/src/lib/email/service.ts | High-level email functions (sendWelcomeEmail, sendContactConfirmation, etc.) |
apps/boilerplate/src/lib/email/client.ts | Resend SDK client with retry logic and exponential backoff |
apps/boilerplate/src/lib/email/types.ts | TypeScript types for email options, webhook data |
apps/boilerplate/src/lib/db/queries/email-logs.ts | Database queries for EmailLog (log, update status, check duplicates) |
apps/boilerplate/src/emails/templates/ | 6 React Email templates |
apps/boilerplate/src/emails/components/ | Shared email components (header, footer, button) |
apps/boilerplate/src/app/api/webhooks/resend/route.ts | Webhook endpoint with signature verification |