How to Send Emails in Nuxt (Nuxt 3, 2026 Guide)

Most Nuxt email tutorials show a server route that sends one email and stop. That misses password resets, payment receipts, Stripe webhooks, HTML templates, error handling, and everything else a production app needs.
Nuxt 3 is built on Nitro, which gives you server routes, auto-imported utilities, and runtime config, all designed to keep secrets off the client. The server/ directory is a security boundary: nothing inside it ships to the browser. And Nitro's universal deployment means your email code works on Node.js, Vercel, Cloudflare, Deno Deploy, or anywhere else.
This guide covers the full picture with working TypeScript examples. For other full-stack frameworks, see our guides for Next.js, SvelteKit, or Remix.
Pick a Provider
Every code example below lets you switch between three providers.
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one SDK. Native Stripe integration and built-in retries.
- Resend is developer-friendly. Clean API, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise option. Feature-rich, high volume. Bigger API surface.
Install
npm install sequenzynpm install resendnpm install @sendgrid/mailAdd your API key to .env:
SEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereRegister the key in nuxt.config.ts under runtimeConfig (not runtimeConfig.public):
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// Server-only — never exposed to the client
sequenzyApiKey: process.env.SEQUENZY_API_KEY,
resendApiKey: process.env.RESEND_API_KEY,
sendgridApiKey: process.env.SENDGRID_API_KEY,
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
},
});Initialize the Client
Create a server utility in server/utils/. Nitro auto-imports everything from this directory into server routes, so you don't need manual imports.
import Sequenzy from "sequenzy";
let client: Sequenzy | null = null;
export function useEmail() {
if (!client) {
const config = useRuntimeConfig();
client = new Sequenzy({ apiKey: config.sequenzyApiKey });
}
return client;
}import { Resend } from "resend";
let client: Resend | null = null;
export function useEmail() {
if (!client) {
const config = useRuntimeConfig();
client = new Resend(config.resendApiKey);
}
return client;
}import sgMail from "@sendgrid/mail";
let initialized = false;
export function useEmail() {
if (!initialized) {
const config = useRuntimeConfig();
sgMail.setApiKey(config.sendgridApiKey);
initialized = true;
}
return sgMail;
}The singleton pattern (let client = null) prevents re-creating the SDK on every request. useRuntimeConfig() is Nitro's auto-imported function for accessing server-only config.
Send Your First Email
A contact form that posts to a Nitro server route. The route sends the email, and the Vue component handles the form state.
export default defineEventHandler(async (event) => {
const { email, name, message } = await readBody(event);
if (!email || !name || !message) {
throw createError({
statusCode: 400,
message: "All fields are required",
});
}
const sequenzy = useEmail();
try {
await sequenzy.transactional.send({
to: "you@yourcompany.com",
subject: `Contact from ${name}`,
body: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
return { success: true };
} catch {
throw createError({ statusCode: 500, message: "Failed to send message" });
}
});export default defineEventHandler(async (event) => {
const { email, name, message } = await readBody(event);
if (!email || !name || !message) {
throw createError({
statusCode: 400,
message: "All fields are required",
});
}
const resend = useEmail();
const { error } = await resend.emails.send({
from: "Contact <noreply@yourdomain.com>",
to: "you@yourcompany.com",
subject: `Contact from ${name}`,
html: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
if (error) {
throw createError({ statusCode: 500, message: "Failed to send message" });
}
return { success: true };
});export default defineEventHandler(async (event) => {
const { email, name, message } = await readBody(event);
if (!email || !name || !message) {
throw createError({
statusCode: 400,
message: "All fields are required",
});
}
const sgMail = useEmail();
try {
await sgMail.send({
to: "you@yourcompany.com",
from: "noreply@yourdomain.com",
subject: `Contact from ${name}`,
html: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
return { success: true };
} catch {
throw createError({ statusCode: 500, message: "Failed to send message" });
}
});The Vue page:
<!-- pages/contact.vue -->
<script setup lang="ts">
const form = reactive({ email: '', name: '', message: '' });
const status = ref<'idle' | 'sending' | 'sent' | 'error'>('idle');
const errorMessage = ref('');
async function handleSubmit() {
status.value = 'sending';
errorMessage.value = '';
try {
await $fetch('/api/contact', {
method: 'POST',
body: form,
});
status.value = 'sent';
form.email = '';
form.name = '';
form.message = '';
} catch (err: any) {
status.value = 'error';
errorMessage.value = err.data?.message || 'Failed to send message';
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="form.name" placeholder="Your name" required />
<input v-model="form.email" type="email" placeholder="Your email" required />
<textarea v-model="form.message" placeholder="Your message" required />
<button type="submit" :disabled="status === 'sending'">
{{ status === 'sending' ? 'Sending...' : 'Send Message' }}
</button>
<p v-if="status === 'error'" style="color: red">{{ errorMessage }}</p>
<p v-if="status === 'sent'" style="color: green">Message sent!</p>
</form>
</template>Key Nuxt patterns:
- File-based routing:
server/api/contact.post.tsmaps toPOST /api/contact. The.postsuffix limits it to POST requests. - Auto-imports:
defineEventHandler,readBody,createError, anduseEmailare all auto-imported. No import statements needed. $fetch: Nuxt's smart fetch. During SSR, it calls the server route directly (no HTTP roundtrip). On the client, it makes a normal fetch request.readBody: Parses the request body. Handles JSON automatically.
React Email Templates
Inline HTML strings don't scale. React Email lets you build email templates as components that compile to email-safe HTML. You can use React Email in a Nuxt project since it only runs server-side to produce HTML strings.
npm install @react-email/components react-email react react-domCreate templates in server/emails/:
// server/emails/layout.tsx
import {
Html,
Head,
Body,
Container,
Text,
Hr,
} from "@react-email/components";
interface EmailLayoutProps {
children: React.ReactNode;
preview?: string;
}
export function EmailLayout({ children, preview }: EmailLayoutProps) {
return (
<Html>
<Head />
<Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
<Container
style={{
backgroundColor: "#ffffff",
padding: "40px",
borderRadius: "8px",
margin: "40px auto",
maxWidth: "560px",
}}
>
{children}
<Hr style={{ borderColor: "#e6ebf1", margin: "32px 0" }} />
<Text style={{ color: "#8898aa", fontSize: "12px" }}>
YourApp Inc. · 123 Main St · San Francisco, CA
</Text>
</Container>
</Body>
</Html>
);
}// server/emails/welcome.tsx
import { Text, Button, Heading } from "@react-email/components";
import { EmailLayout } from "./layout";
interface WelcomeEmailProps {
name: string;
loginUrl: string;
}
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
return (
<EmailLayout preview={`Welcome to YourApp, ${name}`}>
<Heading as="h1" style={{ fontSize: "24px", color: "#1a1a1a" }}>
Welcome, {name}!
</Heading>
<Text style={{ fontSize: "16px", color: "#4a4a4a", lineHeight: "26px" }}>
Your account is ready. Here's what to do next:
</Text>
<Text style={{ fontSize: "16px", color: "#4a4a4a", lineHeight: "26px" }}>
1. Set up your first project{"\n"}
2. Invite your team{"\n"}
3. Connect your integrations
</Text>
<Button
href={loginUrl}
style={{
backgroundColor: "#5046e5",
color: "#ffffff",
padding: "12px 24px",
borderRadius: "6px",
textDecoration: "none",
display: "inline-block",
marginTop: "16px",
}}
>
Go to Dashboard
</Button>
</EmailLayout>
);
}Create a server utility to render and send:
import { render } from "@react-email/components";
import { WelcomeEmail } from "~/server/emails/welcome";
export async function sendWelcomeEmail(to: string, name: string) {
const sequenzy = useEmail();
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);
return sequenzy.transactional.send({
to,
subject: `Welcome to YourApp, ${name}!`,
body: html,
});
}import { render } from "@react-email/components";
import { WelcomeEmail } from "~/server/emails/welcome";
export async function sendWelcomeEmail(to: string, name: string) {
const resend = useEmail();
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);
return resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to,
subject: `Welcome to YourApp, ${name}!`,
html,
});
}import { render } from "@react-email/components";
import { WelcomeEmail } from "~/server/emails/welcome";
export async function sendWelcomeEmail(to: string, name: string) {
const sgMail = useEmail();
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);
return sgMail.send({
to,
from: "noreply@yourdomain.com",
subject: `Welcome to YourApp, ${name}!`,
html,
});
}Since sendWelcomeEmail is in server/utils/, it's auto-imported in all server routes. Call it directly:
// server/api/signup.post.ts
export default defineEventHandler(async (event) => {
const { email, name, password } = await readBody(event);
// ... create user account ...
await sendWelcomeEmail(email, name);
return { success: true };
});Common SaaS Patterns
Password Reset
import { render } from "@react-email/components";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";
function ResetEmail({ url }: { url: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
<Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
<Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
Reset Password
</Button>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
If you didn't request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
export default defineEventHandler(async (event) => {
const { email } = await readBody(event);
if (!email) {
throw createError({ statusCode: 400, message: "Email is required" });
}
// Always return success to prevent email enumeration
const user = await db.user.findByEmail(email);
if (!user) {
return { success: true };
}
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000);
await db.resetToken.create({ userId: user.id, token, expires });
const config = useRuntimeConfig();
const resetUrl = `${getRequestURL(event).origin}/reset-password?token=${token}`;
const html = await render(<ResetEmail url={resetUrl} />);
const sequenzy = useEmail();
await sequenzy.transactional.send({
to: email,
subject: "Reset your password",
body: html,
});
return { success: true };
});import { render } from "@react-email/components";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";
function ResetEmail({ url }: { url: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
<Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
<Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
Reset Password
</Button>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
If you didn't request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
export default defineEventHandler(async (event) => {
const { email } = await readBody(event);
if (!email) {
throw createError({ statusCode: 400, message: "Email is required" });
}
const user = await db.user.findByEmail(email);
if (!user) {
return { success: true };
}
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000);
await db.resetToken.create({ userId: user.id, token, expires });
const resetUrl = `${getRequestURL(event).origin}/reset-password?token=${token}`;
const html = await render(<ResetEmail url={resetUrl} />);
const resend = useEmail();
await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to: email,
subject: "Reset your password",
html,
});
return { success: true };
});import { render } from "@react-email/components";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";
function ResetEmail({ url }: { url: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
<Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
<Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
Reset Password
</Button>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
If you didn't request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
export default defineEventHandler(async (event) => {
const { email } = await readBody(event);
if (!email) {
throw createError({ statusCode: 400, message: "Email is required" });
}
const user = await db.user.findByEmail(email);
if (!user) {
return { success: true };
}
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000);
await db.resetToken.create({ userId: user.id, token, expires });
const resetUrl = `${getRequestURL(event).origin}/reset-password?token=${token}`;
const html = await render(<ResetEmail url={resetUrl} />);
const sgMail = useEmail();
try {
await sgMail.send({
to: email,
from: "noreply@yourdomain.com",
subject: "Reset your password",
html,
});
} catch {
console.error("Failed to send password reset email");
}
return { success: true };
});getRequestURL(event) is a Nitro auto-import that gives you the current request's full URL. The .origin property gives you the base URL without hardcoding it.
Payment Receipt
import { render } from "@react-email/components";
import {
Html, Body, Container, Text, Heading, Hr,
Row, Column, Section,
} from "@react-email/components";
interface ReceiptProps {
customerName: string;
items: { name: string; amount: string }[];
total: string;
receiptUrl: string;
}
function ReceiptEmail({ customerName, items, total, receiptUrl }: ReceiptProps) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Heading as="h1" style={{ fontSize: "24px" }}>Payment Receipt</Heading>
<Text>Hi {customerName}, thanks for your purchase!</Text>
<Section style={{ marginTop: "24px" }}>
{items.map((item, i) => (
<Row key={i} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}>
<Column>{item.name}</Column>
<Column align="right">{item.amount}</Column>
</Row>
))}
</Section>
<Hr style={{ margin: "16px 0" }} />
<Row>
<Column><Text style={{ fontWeight: "bold" }}>Total</Text></Column>
<Column align="right"><Text style={{ fontWeight: "bold" }}>{total}</Text></Column>
</Row>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "24px" }}>
View your full receipt at {receiptUrl}
</Text>
</Container>
</Body>
</Html>
);
}
export async function sendReceipt(to: string, data: ReceiptProps) {
const sequenzy = useEmail();
const html = await render(<ReceiptEmail {...data} />);
return sequenzy.transactional.send({
to,
subject: `Receipt for your ${data.total} payment`,
body: html,
});
}import { render } from "@react-email/components";
import {
Html, Body, Container, Text, Heading, Hr,
Row, Column, Section,
} from "@react-email/components";
interface ReceiptProps {
customerName: string;
items: { name: string; amount: string }[];
total: string;
receiptUrl: string;
}
function ReceiptEmail({ customerName, items, total, receiptUrl }: ReceiptProps) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Heading as="h1" style={{ fontSize: "24px" }}>Payment Receipt</Heading>
<Text>Hi {customerName}, thanks for your purchase!</Text>
<Section style={{ marginTop: "24px" }}>
{items.map((item, i) => (
<Row key={i} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}>
<Column>{item.name}</Column>
<Column align="right">{item.amount}</Column>
</Row>
))}
</Section>
<Hr style={{ margin: "16px 0" }} />
<Row>
<Column><Text style={{ fontWeight: "bold" }}>Total</Text></Column>
<Column align="right"><Text style={{ fontWeight: "bold" }}>{total}</Text></Column>
</Row>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "24px" }}>
View your full receipt at {receiptUrl}
</Text>
</Container>
</Body>
</Html>
);
}
export async function sendReceipt(to: string, data: ReceiptProps) {
const resend = useEmail();
const html = await render(<ReceiptEmail {...data} />);
return resend.emails.send({
from: "YourApp <billing@yourdomain.com>",
to,
subject: `Receipt for your ${data.total} payment`,
html,
});
}import { render } from "@react-email/components";
import {
Html, Body, Container, Text, Heading, Hr,
Row, Column, Section,
} from "@react-email/components";
interface ReceiptProps {
customerName: string;
items: { name: string; amount: string }[];
total: string;
receiptUrl: string;
}
function ReceiptEmail({ customerName, items, total, receiptUrl }: ReceiptProps) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Heading as="h1" style={{ fontSize: "24px" }}>Payment Receipt</Heading>
<Text>Hi {customerName}, thanks for your purchase!</Text>
<Section style={{ marginTop: "24px" }}>
{items.map((item, i) => (
<Row key={i} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}>
<Column>{item.name}</Column>
<Column align="right">{item.amount}</Column>
</Row>
))}
</Section>
<Hr style={{ margin: "16px 0" }} />
<Row>
<Column><Text style={{ fontWeight: "bold" }}>Total</Text></Column>
<Column align="right"><Text style={{ fontWeight: "bold" }}>{total}</Text></Column>
</Row>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "24px" }}>
View your full receipt at {receiptUrl}
</Text>
</Container>
</Body>
</Html>
);
}
export async function sendReceipt(to: string, data: ReceiptProps) {
const sgMail = useEmail();
const html = await render(<ReceiptEmail {...data} />);
return sgMail.send({
to,
from: "billing@yourdomain.com",
subject: `Receipt for your ${data.total} payment`,
html,
});
}Stripe Webhook
Stripe needs the raw request body for signature verification. In Nitro, use readRawBody instead of readBody:
import Stripe from "stripe";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const stripe = new Stripe(config.stripeSecretKey);
// readRawBody gives you the unparsed request body
const body = await readRawBody(event);
const signature = getRequestHeader(event, "stripe-signature")!;
let stripeEvent: Stripe.Event;
try {
stripeEvent = stripe.webhooks.constructEvent(
body!,
signature,
config.stripeWebhookSecret,
);
} catch (err) {
console.error("Webhook signature verification failed");
throw createError({ statusCode: 400, message: "Invalid signature" });
}
const sequenzy = useEmail();
switch (stripeEvent.type) {
case "checkout.session.completed": {
const session = stripeEvent.data.object as Stripe.Checkout.Session;
const customerEmail = session.customer_details?.email;
if (customerEmail) {
await sendReceipt(customerEmail, {
customerName: session.customer_details?.name || "Customer",
items: [{ name: "Pro Plan", amount: "$29.00" }],
total: `$${(session.amount_total! / 100).toFixed(2)}`,
receiptUrl: "https://yourapp.com/billing",
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = stripeEvent.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(
subscription.customer as string,
) as Stripe.Customer;
if (customer.email) {
await sequenzy.transactional.send({
to: customer.email,
subject: "Your subscription has been cancelled",
body: `
<h2>We're sorry to see you go</h2>
<p>Your subscription has been cancelled.
You still have access until the end of your billing period.</p>
<p>If you change your mind, you can resubscribe anytime.</p>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = stripeEvent.data.object as Stripe.Invoice;
if (invoice.customer_email) {
await sequenzy.transactional.send({
to: invoice.customer_email,
subject: "Payment failed - action required",
body: `
<h2>Your payment didn't go through</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p><a href="https://yourapp.com/billing">Update Payment Method</a></p>
`,
});
}
break;
}
}
return { received: true };
});import Stripe from "stripe";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const stripe = new Stripe(config.stripeSecretKey);
const body = await readRawBody(event);
const signature = getRequestHeader(event, "stripe-signature")!;
let stripeEvent: Stripe.Event;
try {
stripeEvent = stripe.webhooks.constructEvent(
body!,
signature,
config.stripeWebhookSecret,
);
} catch (err) {
console.error("Webhook signature verification failed");
throw createError({ statusCode: 400, message: "Invalid signature" });
}
const resend = useEmail();
switch (stripeEvent.type) {
case "checkout.session.completed": {
const session = stripeEvent.data.object as Stripe.Checkout.Session;
const customerEmail = session.customer_details?.email;
if (customerEmail) {
await sendReceipt(customerEmail, {
customerName: session.customer_details?.name || "Customer",
items: [{ name: "Pro Plan", amount: "$29.00" }],
total: `$${(session.amount_total! / 100).toFixed(2)}`,
receiptUrl: "https://yourapp.com/billing",
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = stripeEvent.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(
subscription.customer as string,
) as Stripe.Customer;
if (customer.email) {
await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to: customer.email,
subject: "Your subscription has been cancelled",
html: `
<h2>We're sorry to see you go</h2>
<p>Your subscription has been cancelled.
You still have access until the end of your billing period.</p>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = stripeEvent.data.object as Stripe.Invoice;
if (invoice.customer_email) {
await resend.emails.send({
from: "YourApp <billing@yourdomain.com>",
to: invoice.customer_email,
subject: "Payment failed - action required",
html: `
<h2>Your payment didn't go through</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p><a href="https://yourapp.com/billing">Update Payment Method</a></p>
`,
});
}
break;
}
}
return { received: true };
});import Stripe from "stripe";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const stripe = new Stripe(config.stripeSecretKey);
const body = await readRawBody(event);
const signature = getRequestHeader(event, "stripe-signature")!;
let stripeEvent: Stripe.Event;
try {
stripeEvent = stripe.webhooks.constructEvent(
body!,
signature,
config.stripeWebhookSecret,
);
} catch (err) {
console.error("Webhook signature verification failed");
throw createError({ statusCode: 400, message: "Invalid signature" });
}
const sgMail = useEmail();
switch (stripeEvent.type) {
case "checkout.session.completed": {
const session = stripeEvent.data.object as Stripe.Checkout.Session;
const customerEmail = session.customer_details?.email;
if (customerEmail) {
await sendReceipt(customerEmail, {
customerName: session.customer_details?.name || "Customer",
items: [{ name: "Pro Plan", amount: "$29.00" }],
total: `$${(session.amount_total! / 100).toFixed(2)}`,
receiptUrl: "https://yourapp.com/billing",
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = stripeEvent.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(
subscription.customer as string,
) as Stripe.Customer;
if (customer.email) {
await sgMail.send({
to: customer.email,
from: "noreply@yourdomain.com",
subject: "Your subscription has been cancelled",
html: `
<h2>We're sorry to see you go</h2>
<p>Your subscription has been cancelled.
You still have access until the end of your billing period.</p>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = stripeEvent.data.object as Stripe.Invoice;
if (invoice.customer_email) {
await sgMail.send({
to: invoice.customer_email,
from: "billing@yourdomain.com",
subject: "Payment failed - action required",
html: `
<h2>Your payment didn't go through</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p><a href="https://yourapp.com/billing">Update Payment Method</a></p>
`,
});
}
break;
}
}
return { received: true };
});The critical function is readRawBody(event) instead of readBody(event). readBody parses JSON, which changes the body and breaks Stripe's signature verification. readRawBody returns the exact bytes Stripe sent.
Error Handling
import Sequenzy from "sequenzy";
interface SendResult {
success: boolean;
error?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
body: string,
): Promise<SendResult> {
const sequenzy = useEmail();
try {
await sequenzy.transactional.send({ to, subject, body });
return { success: true };
} catch (error) {
if (error instanceof Sequenzy.RateLimitError) {
console.warn(`Rate limited. Retry after: ${error.retryAfter}s`);
return { success: false, error: "Too many emails. Try again later." };
}
if (error instanceof Sequenzy.AuthenticationError) {
console.error("Invalid SEQUENZY_API_KEY");
return { success: false, error: "Email service configuration error" };
}
if (error instanceof Sequenzy.ValidationError) {
console.error(`Validation: ${error.message}`);
return { success: false, error: "Invalid email parameters" };
}
console.error("Unexpected email error:", error);
return { success: false, error: "Failed to send email" };
}
}interface SendResult {
success: boolean;
error?: string;
id?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
html: string,
): Promise<SendResult> {
const resend = useEmail();
const { data, error } = await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to,
subject,
html,
});
if (error) {
console.error(`Resend error [${error.name}]: ${error.message}`);
switch (error.name) {
case "rate_limit_exceeded":
return { success: false, error: "Too many emails. Try again later." };
case "validation_error":
case "missing_required_field":
return { success: false, error: "Invalid email parameters" };
case "invalid_api_key":
return { success: false, error: "Email service configuration error" };
default:
return { success: false, error: "Failed to send email" };
}
}
return { success: true, id: data?.id };
}interface SendResult {
success: boolean;
error?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
html: string,
): Promise<SendResult> {
const sgMail = useEmail();
try {
await sgMail.send({
to,
from: "noreply@yourdomain.com",
subject,
html,
});
return { success: true };
} catch (error: unknown) {
const sgError = error as { code?: number; response?: { body?: { errors?: { message: string }[] } } };
if (sgError.code === 429) {
return { success: false, error: "Too many emails. Try again later." };
}
if (sgError.code === 401) {
console.error("Invalid SendGrid API key");
return { success: false, error: "Email service configuration error" };
}
const messages = sgError.response?.body?.errors?.map((e) => e.message);
if (messages?.length) {
console.error("SendGrid errors:", messages);
}
return { success: false, error: "Failed to send email" };
}
}Since it's in server/utils/, sendEmailSafe is auto-imported everywhere:
// server/api/invite.post.ts
export default defineEventHandler(async (event) => {
const { email } = await readBody(event);
const result = await sendEmailSafe(
email,
"You've been invited!",
"<p>Click here to join the team.</p>",
);
if (!result.success) {
throw createError({ statusCode: 500, message: result.error });
}
return { success: true };
});Nitro Middleware for Rate Limiting
Nitro server middleware runs on every request before your route handler. Use it for rate limiting:
// server/middleware/rate-limit.ts
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
export default defineEventHandler((event) => {
// Only rate limit email-related endpoints
const path = getRequestURL(event).pathname;
if (!path.startsWith("/api/contact") && !path.startsWith("/api/send")) {
return;
}
const ip = getRequestIP(event, { xForwardedFor: true }) || "unknown";
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 });
return;
}
if (entry.count >= 5) {
throw createError({
statusCode: 429,
message: "Too many requests. Try again later.",
});
}
entry.count++;
});For production, use Redis instead of the in-memory map.
Production Checklist
1. Verify Your Sending Domain
| Record | Type | Purpose |
|---|---|---|
| SPF | TXT | Authorizes servers to send |
| DKIM | TXT | Cryptographic signature |
| DMARC | TXT | Policy for failed checks |
Our email authentication guide covers the full DNS setup process.
2. Server-Only Configuration
Use runtimeConfig (not runtimeConfig.public) for all API keys. Nuxt guarantees these never reach the client.
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// Server-only (correct)
sequenzyApiKey: "",
stripeSecretKey: "",
// public: { ... } — client-visible, never put secrets here
},
});3. Input Validation
Use Zod in your server routes:
// server/api/contact.post.ts
import { z } from "zod";
const contactSchema = z.object({
email: z.string().email("Invalid email"),
name: z.string().min(1).max(100),
message: z.string().min(10).max(5000),
});
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
throw createError({
statusCode: 400,
message: parsed.error.issues.map((i) => i.message).join(", "),
});
}
const { email, name, message } = parsed.data;
// ... send email
});4. File Structure
server/
api/
contact.post.ts # Contact form
forgot-password.post.ts # Password reset
stripe-webhook.post.ts # Stripe webhook
subscribe.post.ts # Newsletter signup
emails/
layout.tsx # React Email layout
welcome.tsx # Welcome template
receipt.tsx # Receipt template
middleware/
rate-limit.ts # Rate limiting
utils/
email.ts # SDK client
send-welcome.ts # Welcome sender
send-receipt.ts # Receipt sender
send-email-safe.ts # Error-safe wrapper
5. Nitro Presets
Nuxt auto-detects the deployment platform, but you can set it explicitly:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: "node-server", // or "vercel", "cloudflare-pages", "deno-server"
},
});Your email code works identically across all presets.
Beyond Transactional
export default defineEventHandler(async (event) => {
const { email, name } = await readBody(event);
if (!email) {
throw createError({ statusCode: 400, message: "Email is required" });
}
const sequenzy = useEmail();
await sequenzy.subscribers.add({
email,
attributes: { name },
tags: ["signed-up"],
});
return { success: true };
});
// Sequenzy triggers welcome sequences automatically
// based on the "signed-up" tag. One SDK handles
// transactional, marketing, and automations.export default defineEventHandler(async (event) => {
const { email, name } = await readBody(event);
if (!email) {
throw createError({ statusCode: 400, message: "Email is required" });
}
const resend = useEmail();
const config = useRuntimeConfig();
const { error } = await resend.contacts.create({
audienceId: config.resendAudienceId,
email,
firstName: name,
});
if (error) {
throw createError({ statusCode: 500, message: error.message });
}
return { success: true };
});
// Resend has one-off broadcasts but no automated
// sequences. You'd need another tool for that.export default defineEventHandler(async (event) => {
const { email, name } = await readBody(event);
if (!email) {
throw createError({ statusCode: 400, message: "Email is required" });
}
const config = useRuntimeConfig();
const response = await fetch("https://api.sendgrid.com/v3/marketing/contacts", {
method: "PUT",
headers: {
Authorization: `Bearer ${config.sendgridApiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
list_ids: [config.sendgridListId],
contacts: [{ email, first_name: name }],
}),
});
if (!response.ok) {
throw createError({ statusCode: 500, message: "Failed to subscribe" });
}
return { success: true };
});
// SendGrid has marketing automation via a separate
// Marketing Campaigns plan.Sequenzy handles transactional sends, marketing campaigns, automated welcome sequences, and subscriber management from one SDK. Native Stripe integration tags subscribers automatically when they purchase, cancel, or churn.
FAQ
Can I use Nodemailer with Nuxt?
Yes, but it's not ideal for production. Nodemailer connects to SMTP servers directly, which means no delivery analytics, bounce handling, or reputation management. API-based providers handle all that for you. Nodemailer works fine for development or internal apps.
What's the difference between runtimeConfig and runtimeConfig.public?
runtimeConfig values are server-only. They're available in server/ code via useRuntimeConfig() but never sent to the client. runtimeConfig.public values are exposed to both server and client. API keys should always be in runtimeConfig (without public).
What does readRawBody do that readBody doesn't?
readBody parses the request body (JSON, form data, etc.). readRawBody returns the raw bytes/string as the client sent them. For Stripe webhooks, you need the exact raw body to verify the signature. If you parse it first, the signature verification fails because the body has been transformed.
Why use server/utils/ instead of regular server files?
Files in server/utils/ are auto-imported by Nitro in all server routes. You don't need import { useEmail } from '~/server/utils/email', you just call useEmail() directly. It works like Nuxt's auto-import system but for server code.
Can I use Vue Email instead of React Email?
Vue Email exists but has a much smaller ecosystem than React Email. React Email has more components, better documentation, and wider community adoption. Since email templates only run server-side (they produce HTML strings), using React on the server doesn't conflict with Vue on the client. Your users never download React.
How does $fetch differ from regular fetch?
$fetch is Nuxt's enhanced fetch. During SSR, when your Vue component calls $fetch('/api/contact'), it calls the server route function directly without making an HTTP request (zero network overhead). On the client, it makes a normal fetch request. It also automatically parses JSON responses and throws on non-2xx status codes.
How do I send emails without blocking the response?
For non-critical emails, fire and forget:
export default defineEventHandler(async (event) => {
const { email, name } = await readBody(event);
// ... create user ...
// Don't await — email sends in background
void sendWelcomeEmail(email, name);
return { success: true };
});For production, use a job queue (BullMQ, Inngest) to handle email sending asynchronously with retries and error tracking.
Does my email code work on Cloudflare/Vercel/Deno?
Yes. Nitro abstracts the runtime, so your server routes work across all deployment targets. The email SDK makes HTTP API calls, which work in any JavaScript runtime. If you're on Cloudflare Workers, just make sure your email SDK doesn't use Node-only APIs (Sequenzy and Resend work in all runtimes).
Wrapping Up
- Server routes (
server/api/*.ts) handle email sending with auto-imported utilities server/utils/auto-imports across all server routes for clean coderuntimeConfigkeeps API keys server-only at the framework levelreadRawBodygives raw body for Stripe webhook signature verification- React Email works server-side for maintainable templates
- Nitro middleware handles rate limiting across all endpoints
- Universal deployment means your email code works on any platform
Pick your provider, copy the patterns, and ship.
Frequently Asked Questions
Can I send emails from Nuxt without server routes?
No. Email API keys must stay on the server. You need Nuxt's server routes (server/api/) or server middleware to handle email sending. Client-side code should call these server endpoints via $fetch or useFetch.
How do I create an email-sending API endpoint in Nuxt 3?
Create a file in server/api/ (e.g., server/api/send-email.post.ts). Export a defineEventHandler that reads the request body, calls your email SDK, and returns the result. Nuxt automatically creates the API route from the file name.
How do I handle environment variables for email keys in Nuxt?
Add keys to .env and access them in server routes with useRuntimeConfig().apiKey. Define the keys in nuxt.config.ts under runtimeConfig (private) to ensure they're only available server-side, never exposed to the client.
Can I use Vue composables for email form submissions?
Yes. Create a composable like useEmailForm() that wraps useFetch or $fetch to call your server API endpoint. Handle loading states, errors, and success responses in the composable for reuse across multiple components.
How do I send emails in the background in Nuxt?
For simple cases, fire the email send without awaiting in a Nitro task. For production workloads, use a job queue (BullMQ with a separate worker) since Nuxt's serverless functions have execution time limits on most hosting platforms.
How do I test Nuxt email server routes?
Use $fetch in your test setup to call the server route directly. Mock the email SDK to prevent real sends. Nuxt's test utilities let you spin up a test server that handles your API routes for integration testing.
Does Nuxt's auto-import work with email SDKs in server routes?
Nuxt auto-imports from server/utils/ in server routes. Create a server/utils/email.ts file that exports your configured email client. It's automatically available in all server routes without explicit imports.
How do I add rate limiting to Nuxt email endpoints?
Use Nuxt's server middleware or the nuxt-rate-limit module to limit requests per IP. Apply it specifically to your email-sending routes. For more control, use unstorage with Redis to track request counts across serverless instances.
Can I use Nuxt with React Email for templates?
Not directly, since Nuxt uses Vue. For email templates in Nuxt, use MJML (framework-agnostic email templating) or build HTML templates with Nuxt's server-side rendering. You can also pre-compile React Email templates to HTML and use the output strings.
How do I handle form validation before sending emails in Nuxt?
Use Zod schemas shared between client and server. Validate on the client with VeeValidate or FormKit for instant feedback, then validate again on the server in your API handler. Never trust client-side validation alone.