Back to Blog

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

12 min read

Nuxt 3 runs on Nitro, which gives you server routes that work like any Node.js backend. You can send emails directly from your Nuxt app without a separate server. API keys stay in runtimeConfig on the server side, never exposed to the client.

This guide covers server routes, composables, and the patterns you need for production email sending in Nuxt.

Pick a Provider

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences from one SDK. Native Stripe integration.
  • Resend is developer-friendly. They have one-off broadcast campaigns but no automations or sequences.
  • SendGrid is the enterprise option. Good for high volume.

Install

Terminal
npm install sequenzy
Terminal
npm install resend
Terminal
npm install @sendgrid/mail

Configure in nuxt.config.ts:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Server-only keys (never exposed to client)
    sequenzyApiKey: process.env.SEQUENZY_API_KEY,
    resendApiKey: process.env.RESEND_API_KEY,
    sendgridApiKey: process.env.SENDGRID_API_KEY,
  },
});

Create a Server Utility

server/utils/email.ts
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;
}
server/utils/email.ts
import { Resend } from "resend";

let client: Resend | null = null;

export function useEmail() {
if (!client) {
  const config = useRuntimeConfig();
  client = new Resend(config.resendApiKey);
}
return client;
}
server/utils/email.ts
import sgMail from "@sendgrid/mail";

let initialized = false;

export function useEmail() {
if (!initialized) {
  const config = useRuntimeConfig();
  sgMail.setApiKey(config.sendgridApiKey);
  initialized = true;
}
return sgMail;
}

Send from a Server Route

server/api/send-welcome.post.ts
export default defineEventHandler(async (event) => {
const { email, name } = await readBody(event);

if (!email || !name) {
  throw createError({ statusCode: 400, message: "email and name required" });
}

const sequenzy = useEmail();

const result = await sequenzy.transactional.send({
  to: email,
  subject: `Welcome, ${name}`,
  body: `<h1>Welcome, ${name}</h1><p>Your account is ready.</p>`,
});

return { jobId: result.jobId };
});
server/api/send-welcome.post.ts
export default defineEventHandler(async (event) => {
const { email, name } = await readBody(event);

if (!email || !name) {
  throw createError({ statusCode: 400, message: "email and name required" });
}

const resend = useEmail();

const { data, error } = await resend.emails.send({
  from: "Your App <noreply@yourdomain.com>",
  to: email,
  subject: `Welcome, ${name}`,
  html: `<h1>Welcome, ${name}</h1><p>Your account is ready.</p>`,
});

if (error) {
  throw createError({ statusCode: 500, message: error.message });
}

return { id: data?.id };
});
server/api/send-welcome.post.ts
export default defineEventHandler(async (event) => {
const { email, name } = await readBody(event);

if (!email || !name) {
  throw createError({ statusCode: 400, message: "email and name required" });
}

const sgMail = useEmail();

try {
  await sgMail.send({
    to: email,
    from: "noreply@yourdomain.com",
    subject: `Welcome, ${name}`,
    html: `<h1>Welcome, ${name}</h1><p>Your account is ready.</p>`,
  });
  return { sent: true };
} catch {
  throw createError({ statusCode: 500, message: "Failed to send" });
}
});

Call from a Vue component:

<!-- pages/contact.vue -->
<script setup lang="ts">
const email = ref('');
const name = ref('');
const status = ref<'idle' | 'sending' | 'sent' | 'error'>('idle');
 
async function handleSubmit() {
  status.value = 'sending';
  try {
    await $fetch('/api/send-welcome', {
      method: 'POST',
      body: { email: email.value, name: name.value },
    });
    status.value = 'sent';
  } catch {
    status.value = 'error';
  }
}
</script>
 
<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="email" type="email" placeholder="Email" required />
    <input v-model="name" placeholder="Name" required />
    <button type="submit" :disabled="status === 'sending'">
      {{ status === 'sending' ? 'Sending...' : 'Send' }}
    </button>
    <p v-if="status === 'sent'" style="color: green">Email sent!</p>
    <p v-if="status === 'error'" style="color: red">Failed to send</p>
  </form>
</template>

Going to Production

1. Verify Your Domain

Add SPF, DKIM, DMARC DNS records through your provider's dashboard.

2. Keep Keys Server-Side

Only use runtimeConfig (not runtimeConfig.public) for API keys. Nuxt guarantees server-only keys never reach the client.

3. Use $fetch for API Calls

Nuxt's $fetch handles SSR and client-side transparently. During SSR it calls the server route directly (no HTTP overhead).

Beyond Transactional

Sequenzy handles transactional sends, marketing campaigns, automated sequences, and subscriber management from one SDK. Native Stripe integration for SaaS.

Wrapping Up

  1. Nitro server routes for API-style email endpoints
  2. Server utilities for reusable email clients
  3. runtimeConfig for secure API key management
  4. $fetch for seamless client-server communication

Pick your provider, copy the patterns, and start sending.