Docs / Nuxt

Send email in Nuxt over SMTP

Nuxt has no mail layer of its own, so sending email is Nitro work: a server route calls an SMTP client, and the page just posts to it. This guide wires Nodemailer up to Postwing for Nuxt 3 and Nuxt 4 — credentials in runtimeConfig, the transport as a server util, Vue Email templates, and the deployment presets on which SMTP cannot work at all.

You can get them on the token management page. For security reasons, a token is shown only once — at the moment it is created.

SMTP connection settings

SettingValue
SMTP hostsmtp.postwing.app
Port587
EncryptionSTARTTLS (the connection is upgraded to TLS before login)
UsernameThe login of an SMTP token for your domain
PasswordThe password of that token — shown once, when the token is created
Every mode is also available on a high port: 8465 (SSL/TLS), 8587 (STARTTLS) and 8025 (plain). Many hosting providers and clouds block outbound 25, 465 and 587 — if the connection times out, switch to the matching high port.

Install Nodemailer

bash
npm install nodemailer
npm install -D @types/nodemailer

Put the credentials in runtimeConfig

Keys declared at the top level of runtimeConfig are server-only, and each one is overridden at boot by the environment variable with the matching NUXT_ name — so runtimeConfig.smtp.pass comes from NUXT_SMTP_PASS without any code reading process.env:

nuxt.config.ts
// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Private — server-side only. Nothing here reaches the browser.
    smtp: {
      user: "",   // filled from NUXT_SMTP_USER
      pass: "",   // filled from NUXT_SMTP_PASS
      from: "",   // filled from NUXT_SMTP_FROM
    },
    public: {
      // Never put mail credentials here — this object is serialised into the page.
    },
  },
});
.env
NUXT_SMTP_USER=token-login@your-domain.com
NUXT_SMTP_PASS=your-token-password
NUXT_SMTP_FROM="Acme <noreply@your-domain.com>"
Everything in public is serialised into the HTML of every page and readable with view-source. An SMTP token exposed that way lets anyone send mail as your domain. The private half of runtimeConfig never leaves the server.

Create the transport as a server util

Anything in server/utils/ is auto-imported across server code, so the transport is built once and reused rather than rebuilt per request:

server/utils/mailer.ts
// server/utils/mailer.ts — auto-imported in any server route
import nodemailer, { type Transporter } from "nodemailer";

let transporter: Transporter | undefined;

// One transport per server instance, not one per request: a new transport means
// a fresh TCP connection and a fresh TLS handshake on every send.
export function useMailer(): Transporter {
  if (!transporter) {
    const { smtp } = useRuntimeConfig();

    transporter = nodemailer.createTransport({
      host: "smtp.postwing.app",
      port: 587,
      secure: false,        // false on 587 — STARTTLS is negotiated
      requireTLS: true,
      auth: {
        user: smtp.user,
        pass: smtp.pass,    // "pass", not "password"
      },
      pool: true,
      maxConnections: 3,
    });
  }

  return transporter;
}

Send from a server route

server/api/contact.post.ts
// server/api/contact.post.ts
export default defineEventHandler(async (event) => {
  const { email, message } = await readBody<{ email: string; message: string }>(event);

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

  const { smtp } = useRuntimeConfig(event);

  try {
    const info = await useMailer().sendMail({
      from: smtp.from,
      to: "sales@your-domain.com",
      replyTo: email,          // From stays on your domain; replies reach the visitor
      subject: "New contact form submission",
      text: message,
    });

    return { ok: true, messageId: info.messageId };
  } catch (error) {
    console.error("Email failed", error);
    throw createError({ statusCode: 502, statusMessage: "Could not send the message" });
  }
});

Note replyTo: the from address stays on your verified domain, which is what DKIM and SPF are published for, while replies still reach the visitor. Putting their address in from is what makes contact-form mail fail DMARC.

Call it from a page

The form posts to the route with $fetch. Use $fetch here rather than useFetch — this is an action triggered by a submit, not data the page needs to render:

pages/contact.vue
<!-- pages/contact.vue -->
<script setup lang="ts">
const form = reactive({ email: "", message: "" });
const pending = ref(false);
const sent = ref(false);

async function submit() {
  pending.value = true;
  try {
    await $fetch("/api/contact", { method: "POST", body: form });
    sent.value = true;
  } finally {
    pending.value = false;
  }
}
</script>

<template>
  <form v-if="!sent" @submit.prevent="submit">
    <input v-model="form.email" type="email" required >
    <textarea v-model="form.message" required />
    <button type="submit" :disabled="pending">Send</button>
  </form>
  <p v-else>Thanks — we'll be in touch.</p>
</template>
Importing it from a component, composable or plugin pulls node:net and node:dns into the client bundle: Vite warns that the built-ins were externalised for browser compatibility and the call fails in the browser. Files under server/ are the only ones Nuxt never ships to the client.

HTML emails with Vue Email

Hand-writing table-based email HTML is miserable. Vue Email lets you build the message as a Vue component and render it to a string on the server — the same component model as the rest of the app:

emails/OrderConfirmed.vue
<!-- emails/OrderConfirmed.vue -->
<script setup lang="ts">
import { Html, Head, Body, Container, Heading, Text } from "@vue-email/components";

defineProps<{ orderId: number }>();
</script>

<template>
  <Html>
    <Head />
    <Body>
      <Container>
        <Heading>Order #{{ orderId }} confirmed</Heading>
        <Text>Your order ships tomorrow.</Text>
      </Container>
    </Body>
  </Html>
</template>
javascript
// server/api/orders.post.ts
import { render } from "@vue-email/render";
import OrderConfirmed from "~~/emails/OrderConfirmed.vue";

const props = { orderId: order.id };

await useMailer().sendMail({
  from: smtp.from,
  to: order.email,
  subject: `Order #${order.id} confirmed`,
  html: await render(OrderConfirmed, props),
  text: await render(OrderConfirmed, props, { plainText: true }),
});

Rendering the same component twice — once as HTML, once with plainText — is how you get the text part without maintaining a second copy of the wording. Always send both.

Don't block the response

An SMTP handshake plus the send takes a few hundred milliseconds, and a checkout request should not wait for it:

server/api/orders.post.ts
// server/api/orders.post.ts
export default defineEventHandler(async (event) => {
  const order = await createOrder(event);

  // Respond now; Nitro keeps the handler alive until the send settles.
  event.waitUntil(
    sendOrderEmail(order).catch((error) => console.error("Order email failed", error)),
  );

  return order;
});

That is enough for mail nobody would miss. For anything that must survive a restart or be retried, write the job to a queue or a table and send it from a Nitro task or a separate worker.

The cloudflare, vercel-edge and netlify-edge Nitro presets run where there are no TCP sockets, so Nodemailer cannot run there at all. Deploy the default node-server preset, or send over the REST API, which is a plain $fetch over HTTPS and works on every preset.

Troubleshooting

ErrorCause and fix
Module "net" has been externalized for browser compatibility Nodemailer was imported outside server/. Move it to server/utils/ and call a server route from the page.
EAUTH — Invalid login: 535password: used instead of pass:, or wrong token credentials.
smtp.user is an empty string in production The variable lives in .env only — that file is not read from the build output. Set NUXT_SMTP_USER in the deployment environment.
Credentials visible in the page source They were declared under runtimeConfig.public. Move them to the top level and rotate the token.
ETIMEDOUT / ECONNREFUSED after deploying Host blocks outbound SMTP. Try 8587, or switch to the REST API.
node:net unsupported on the deployed worker An edge Nitro preset. Use node-server or the REST API.
First page render is slow after adding mail Sending happens during SSR. Send from a POST route, and hand long sends to event.waitUntil().

Frequently asked questions

Where does email-sending code go in a Nuxt app?

In the server/ directory only — a server route under server/api/, with the transport in server/utils/. That code runs in Nitro on the server and is never bundled into the client, which is what keeps your SMTP credentials out of the browser. Pages, components, composables and plugins are the wrong place for it.

Can I send email from a composable or a Vue component?

No. Nodemailer opens a raw TCP socket, which a browser cannot do, and importing it outside server/ pulls node:net and node:dns into the client bundle — Vite will warn about externalised Node built-ins and the code fails at runtime. Call a server route with $fetch instead and do the sending there.

How do I store SMTP credentials in Nuxt?

Put them in the private part of runtimeConfig in nuxt.config.ts and fill them from environment variables named NUXT_<KEY> — runtimeConfig.smtp.pass is read from NUXT_SMTP_PASS. Never put them under runtimeConfig.public: that object is serialised into the HTML of every page.

Do I need a module like nuxt-nodemailer or nuxt-mail?

No. A transport in server/utils/mailer.ts is about ten lines and is auto-imported everywhere in server code, so a module mostly adds a dependency that has to keep up with Nuxt releases. Modules are fine if you prefer one, but nothing in Nuxt requires it.

Why does sending work in dev but not after deployment?

Three usual causes: the .env file is a development convenience and is not read from the production build output, so the NUXT_* variables have to exist in the deployment environment; the host blocks outbound port 587; or the Nitro preset has no TCP sockets at all. Check which of the three applies before changing the code.

Can I send email from Cloudflare Workers or an edge preset?

Not over SMTP. The cloudflare, vercel-edge and netlify-edge presets run on a runtime with no TCP sockets, so Nodemailer cannot work there. Use the REST API over HTTPS — it is a plain $fetch and runs on every preset — or deploy the default node-server preset.

How do I send email without blocking the response?

Hand the promise to event.waitUntil() and return the response immediately; Nitro keeps the handler alive until it settles. For anything that must survive a crash or be retried, write the job to a queue or a database table and send it from a Nitro task or a separate worker instead.

Next steps