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.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
npm install nodemailer
npm install -D @types/nodemailer 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
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.
},
},
});NUXT_SMTP_USER=token-login@your-domain.com
NUXT_SMTP_PASS=your-token-password
NUXT_SMTP_FROM="Acme <noreply@your-domain.com>"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. 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 — 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;
}// 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.
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 -->
<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>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. 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 -->
<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>// 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.
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
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.
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. | Error | Cause 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: 535 | password: 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(). |
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.
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.
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.
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.
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.
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.
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.