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 login code</h1>
<p>Use this one-time code to sign in:</p>
<p style="font-size: 24px; font-weight: bold;">{{otp}}</p>
</body>
</html>
`
Template placeholders
Templates use double curly braces for dynamic content:
{{otp}}- One-time password code{{confirmationUrl}}- Action link (email change confirmation){{site_name}}- Your site name from env{{site_domain}}- Your site domain from env{{logo_url}}- Your logo URL{{newEmail}}- New email address (for email changes)
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.