This boilerplate uses Resend by default for sending transactional emails, but is designed with an abstraction layer that makes it easy to swap providers.
Overview
The email system provides:
- Transactional emails - Auth emails, notifications, support messages.
- Templates - HTML email templates included.
- Provider abstraction - Easy to switch email providers.
- Type-safe - Full TypeScript support.
- Server-side only - Emails sent from secure server endpoints.
Configuration
Environment variables
Add your Resend API key to .env:
RESEND_API_KEY="re_..."
CONTACT_FORM_TO_EMAILS="[email protected]"
NUXT_PUBLIC_DISPLAY_CONTACT_EMAIL="[email protected]"
Email configuration
Email settings are in server/utils/config.ts:
export const EMAIL_CONFIG = {
FROM_EMAIL: `${config.public.siteName} <no-reply@${config.public.siteDomain}>`,
} as const
Although the current configuration dynamically uses the site name and domain from environment variables, you can also simply set a static email address instead:
export const EMAIL_CONFIG = {
FROM_EMAIL: '[email protected]',
} as const
Sending emails
The email service is located in server/services/email-server-service.ts:
export async function sendEmail({ to, subject, html }: { to: string; subject: string; html: string }) {
return resend.emails.send({
from: EMAIL_CONFIG.FROM_EMAIL,
to,
subject,
html,
})
}
Example usage
In your API route:
import { sendEmail } from '@@/server/services/email-server-service'
export default defineEventHandler(async event => {
const { email, name } = await readBody(event)
await sendEmail({
to: email,
subject: 'Welcome to our app!',
html: `
<h1>Welcome ${name}!</h1>
<p>Thanks for signing up.</p>
`,
})
return { success: true }
})
Email templates
Pre-built templates are in server/email-templates/:
OTP template
export const otpTemplate = `
<!DOCTYPE html>
<html>
<head>
<style>
/* Email styles */
</style>
</head>
<body>
<h1>Your verification code</h1>
<p>Use this one-time code to sign in:</p>
<p style="font-size: 24px; font-weight: bold;">{{otp}}</p>
</body>
</html>
`
Keep {{otp}} on one line, in an element with no nesting around it. Chrome copies the source
indentation of nested table cells along with the text, so a code laid out across several lines
arrives in the paste buffer padded with tabs.
Welcome template
welcomeTemplate is sent by sendWelcomeEmail from the user.create.after hook in server/utils/auth.ts. Because better-auth is the only thing that creates users, that single call site covers free sign-ups and guest checkouts alike, and every new account receives exactly one.
It deliberately carries no code and no verification link — the address is already verified by the time an account exists, and a second link would compete with the sign-in code. For the same reason it must not mention a purchase: free sign-ups receive the identical email.
Template placeholders
Templates use double curly braces for dynamic content.
Available in every template:
{{site_name}}- Your site name from env{{site_domain}}- Your site domain from env{{logo_url}}- Your logo URL
Specific to individual templates:
{{otp}}- One-time password code (otpTemplate){{verificationUrl}}- Verification link (verifyEmailTemplate){{email}}- Address being verified, which is the new address during an email change (verifyEmailTemplate){{sign_in_url}}- Dashboard link (welcomeTemplate){{deletion_date}},{{cancel_url}}- Grace period end and cancellation link (accountDeletionTemplate)
A placeholder is only replaced by the code that sends that particular email, so adding one from another template leaves the raw {{token}} text in the delivered message.
Using templates
Replace placeholders before sending:
const html = otpTemplate
.replaceAll('{{otp}}', otp)
.replaceAll('{{site_name}}', config.public.siteName)
.replaceAll('{{logo_url}}', logoUrl)
await sendEmail({
to: email,
subject: `🔑 ${otp} - OTP for ${config.public.siteName}`,
html,
})
Creating custom templates
To create a new email template:
- Create the template file:
export const welcomeEmailTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
}
</style>
</head>
<body>
<img src="{{logo_url}}" alt="Logo" style="max-width: 120px;">
<h1>Welcome to {{site_name}}, {{user_name}}!</h1>
<p>We're excited to have you on board.</p>
<a href="{{dashboard_url}}" class="button">Get Started</a>
<p style="color: #666; font-size: 12px;">
© {{site_name}}. All rights reserved.
</p>
</body>
</html>
`
- Use it in your code:
import { sendEmail } from '@@/server/services/email-server-service'
import { welcomeEmailTemplate } from '@@/server/email-templates/welcomeEmailTemplate'
export default defineEventHandler(async event => {
const user = { name: 'John', email: '[email protected]' }
const config = useRuntimeConfig()
// Replace template placeholders
const html = welcomeEmailTemplate
.replaceAll('{{user_name}}', user.name)
.replaceAll('{{site_name}}', config.public.siteName)
.replaceAll('{{logo_url}}', `${config.public.siteUrl}/logo-160px.png`)
.replaceAll('{{dashboard_url}}', `${config.public.siteUrl}/app/dashboard`)
// Send the email
await sendEmail({
to: user.email,
subject: `Welcome to ${config.public.siteName}!`,
html,
})
return { success: true }
})
server/utils/auth.ts for examples). For frequently sent emails, you can extract this into a reusable function.Contact form emails
The template includes a contact form that stores inquiries and sends notification emails. The flow is:
- Frontend sends inquiry payload (
app/components/contact/ContactForm.vue) - Client service calls API (
app/services/contact-requests-client-service.ts) - Server endpoint validates + rate-limits (
server/api/contact-request/create.post.ts) - Server service stores inquiry record (
server/services/contact-requests-server-service.ts) - Email service sends notification (
server/services/email-server-service.ts)
The contact request endpoint includes rate limiting (5 requests per hour per IP):
import { createContactRequestRecord } from '@@/server/services/contact-requests-server-service'
import { sendContactEmail } from '@@/server/services/email-server-service'
import { rateLimit } from '@@/server/utils/rate-limit'
export default defineEventHandler(async event => {
await rateLimit(event, {
max: 5,
window: '1h',
prefix: 'contact-request',
})
const payload = createContactRequestSchema.parse(await readBody(event))
const user = event.context.user
const contactRequest = await createContactRequestRecord({
inquiryType: payload.inquiryType,
name: payload.name,
emailAddress: payload.emailAddress,
subject: payload.subject,
description: payload.description,
userId: typeof user?.id === 'string' ? user.id : null,
})
await sendContactEmail({
subject: payload.subject,
html: emailHtml,
})
return { contactRequest }
})
Inquiry table even when email delivery is degraded.Replacing Resend with another provider
While Resend works great out of the box, you may need to use a different provider based on your existing infrastructure or regional requirements. The email service is designed to make switching providers straightforward.
To switch providers, update server/services/email-server-service.ts with your provider's client. Here's an example using SendGrid:
import sgMail from '@sendgrid/mail'
import { createError } from 'h3'
import { EMAIL_CONFIG } from '../utils/config'
const config = useRuntimeConfig()
sgMail.setApiKey(config.sendgridApiKey)
export async function sendEmail({ to, subject, html }: { to: string; subject: string; html: string }) {
return sgMail.send({
from: EMAIL_CONFIG.FROM_EMAIL,
to,
subject,
html,
})
}
export async function sendContactEmail({ subject, html }: { subject: string; html: string }) {
const targetEmails = (config.contactFormToEmails || '')
.split(/[,;\n]/)
.map((email: string) => email.trim())
.filter(Boolean)
if (targetEmails.length === 0) {
throw createError({
statusCode: 500,
statusMessage: 'Contact email configuration is missing',
})
}
return sgMail.send({
from: EMAIL_CONFIG.FROM_EMAIL,
to: targetEmails,
subject,
html,
})
}
sendEmail and sendContactEmail functions, and add your new provider's API key to .env.Popular email providers
Here are some popular alternatives to Resend:
- SendGrid - sendgrid.com
- Mailgun - mailgun.com
- Postmark - postmarkapp.com
- AWS SES - aws.amazon.com/ses
- Mailchimp Transactional - mailchimp.com/developer/transactional
Best practices
Handle failures gracefully
Don't let email failures block your users:
try {
await sendEmail({ to: user.email, subject: 'Welcome!', html: '...' })
} catch (error) {
logger.error('Failed to send email:', error)
// Log the error but don't throw - user can still continue
}
Common issues
Emails not sending:
- Check your API key in
.env - Verify your sender domain (some providers require verification)
- Check your server logs for errors
- Look in spam folder
Emails look broken:
- Use inline CSS (email clients have limited CSS support)
- Use tables for layout instead of flexbox/grid
- Test with tools like Litmus
Rate limits: For bulk emails, use a proper email marketing service (Mailchimp, SendGrid Marketing) instead of transactional email APIs
Reference
Mobile apps
Use the Nuxt app as the auth backend for iOS, Android, React Native or Ionic clients: bearer sessions, trusted origins, rate limits, and every endpoint.
Payments
Stripe payments with config-driven pricing: one-time purchases and subscriptions, auth and authless checkout, the customer portal, and webhook sync.

