Email Service

Transactional email with Resend and React Email — 6 templates, database logging, and webhook tracking

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.

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

FunctionPurposeRate Limit
sendWelcomeEmail()New user registration1 per 24 hours per email
sendContactConfirmation()Contact form submission1 per 5 minutes per email
sendContactNotificationToAdmin()Admin alert for new contactNo duplicate check
sendSubscriptionEmail()Subscription lifecycle events1 per 30 minutes per email
sendTemplatedEmail()DUAL pricing emails (trial, credits)1 per 30 minutes per email
sendTestEmail()Development/testingNo rate limit

Email Templates

Kit includes 6 React Email templates built with shared components (header, footer, button):
TemplateFileProps
Welcomewelcome.tsxname, email, dashboardUrl
Contact Confirmationcontact-confirmation.tsxname, email, message
Subscription Cancelledsubscription-cancelled.tsxname, planName, endDate
Trial Expired (Free)trial-expired-free.tsxname, freeTierFeatures, upgradeUrl
Trial Expired (Locked)trial-expired-locked.tsxname, pricingUrl
Bonus Credits Purchasedbonus-credits-purchased.tsxname, 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&apos;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&apos;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 text
  • EmailFooter — Adds unsubscribe link, company address, and legal text
  • Button — 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:
  1. Confirmation to the sendersendContactConfirmation() sends a "We received your message" email
  2. Notification to adminsendContactNotificationToAdmin() 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 EventDatabase StatusDescription
email.deliveredDELIVEREDEmail reached recipient's inbox
email.openedOPENEDRecipient opened the email
email.clickedCLICKEDRecipient clicked a link
email.bouncedBOUNCEDEmail bounced (invalid address)
email.complainedFAILEDRecipient marked as spam

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: PENDINGSENTDELIVEREDOPENEDCLICKED. 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 TypeWindowEffect
Welcome24 hoursPrevents duplicate welcome emails on re-registration
Contact Confirmation5 minutesPrevents contact form spam
Subscription Events30 minutesPrevents duplicate subscription notifications
Templated Emails30 minutesPrevents duplicate credit/trial emails
Test EmailNoneAlways sends (development only)

Environment Variables

VariableRequiredDefaultPurpose
RESEND_API_KEYYestest_keyResend API key for sending emails
RESEND_FROM_EMAILYesnoreply@example.comDefault sender email address
RESEND_WEBHOOK_SECRETNoHMAC secret for webhook signature verification
NEXT_PUBLIC_CONTACT_EMAILNoFalls back to RESEND_FROM_EMAILAdmin email for contact form notifications
NEXT_PUBLIC_APP_URLYesBase URL for links in email templates

Key Files

FilePurpose
apps/boilerplate/src/lib/email/service.tsHigh-level email functions (sendWelcomeEmail, sendContactConfirmation, etc.)
apps/boilerplate/src/lib/email/client.tsResend SDK client with retry logic and exponential backoff
apps/boilerplate/src/lib/email/types.tsTypeScript types for email options, webhook data
apps/boilerplate/src/lib/db/queries/email-logs.tsDatabase 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.tsWebhook endpoint with signature verification