How to build a Node.js contact form that sends email

You already have a Node.js server. Maybe it's an Express app serving a portfolio, maybe a small API behind a marketing site. Now you need a contact form, and it feels like it should take fifteen minutes: one route, one call to send an email, done.
The first version usually does take fifteen minutes. Then the first bot finds it, or your provider starts rejecting messages because the From address is wrong, or the SMTP server goes down and messages disappear without anyone noticing.
This guide builds the version that holds up: an Express route that validates input, sends email through Nodemailer over an encrypted SMTP connection, filters basic bots, rate limits by IP, and fails loudly instead of silently.
If your site is static (plain HTML, Hugo, Astro, a site on GitHub Pages) and you don't otherwise run a server, you don't need one for a contact form. HTML contact form that sends email covers that path.
What you're building
The flow has three parts:
- An HTML form that posts to your own endpoint with
fetch(). - An Express route at
POST /api/contactthat validates the input, checks a honeypot field, and applies a rate limit. - Nodemailer, which connects to an SMTP server and hands it the message.
The browser never talks to the mail server directly. It can't: browsers have no way to open an SMTP connection. Every "send email from a form" setup has a server somewhere in the middle, and in this guide that server is yours.
You'll need Node.js 20 or newer (current Nodemailer releases require it) and credentials for an SMTP server. A transactional email provider is the most reliable choice. A regular mailbox's SMTP access works for low volume, but providers often restrict or throttle it.
Set up the project
mkdir contact-form && cd contact-form
npm init -y
npm pkg set type=module
npm install express nodemailer express-rate-limitnpm pkg set type=module lets you use import syntax and top-level await without a build step.
Keep credentials out of your code in a .env file, and add it to .gitignore before your first commit:
# .env
SMTP_HOST=smtp.your-provider.com
SMTP_PORT=587
SMTP_USER=your-smtp-username
SMTP_PASS=your-smtp-password
MAIL_FROM="Contact form <forms@yourdomain.com>"
MAIL_TO=you@yourdomain.comNode can load this file itself with node --env-file=.env server.js, so you don't need a separate package for environment variables.
Configure Nodemailer for SMTP
// mailer.js
import nodemailer from "nodemailer";
const port = Number(process.env.SMTP_PORT ?? 587);
export const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port,
secure: port === 465, // true for implicit TLS (465); 587 upgrades with STARTTLS
requireTLS: port !== 465, // refuse to send credentials over an unencrypted connection
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
export async function sendContactEmail({ name, email, message }) {
return transporter.sendMail({
from: process.env.MAIL_FROM, // an address on a domain you control
to: process.env.MAIL_TO,
replyTo: { name, address: email }, // hitting "Reply" goes to the visitor
subject: `New contact form message from ${name}`,
text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
});
}Two decisions in this file matter more than they look.
The TLS settings. Port 465 uses TLS from the first byte, so secure is true. Port 587 starts unencrypted and upgrades with STARTTLS, so secure is false. On its own, that upgrade is opportunistic: if a misconfigured server doesn't offer STARTTLS, the client could carry on in plaintext. requireTLS: true makes Nodemailer refuse instead, so your SMTP password never crosses the network unencrypted.
The from and replyTo fields. It's tempting to set from to the visitor's email address so you can reply directly. Don't. You don't own their domain, so the message fails the SPF, DKIM, and DMARC checks that receiving servers use to spot forged mail, and it lands in spam or gets rejected outright. Send from an address on your own domain and put the visitor in replyTo. Your mail client's Reply button does the right thing, and the message stays authenticated.
The body is plain text, not html. That's deliberate: if you interpolate user input into an HTML email without escaping it, a visitor can inject links and markup into a message that arrives looking like it came from your own system. Plain text sidesteps the problem entirely.
Validate on the server
The browser's required and type="email" attributes catch honest mistakes, but anyone can send a request straight to your endpoint. The server has to check everything again.
// validate.js
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function validateContact(body) {
const name = String(body.name ?? "").trim();
const email = String(body.email ?? "").trim();
const message = String(body.message ?? "").trim();
const errors = {};
// Name and email end up in email headers, so reject line breaks outright.
if (!name || name.length > 100 || /[\r\n]/.test(name)) {
errors.name = "Please enter your name (up to 100 characters).";
}
if (!EMAIL_RE.test(email) || email.length > 254) {
errors.email = "Please enter a valid email address.";
}
if (message.length < 10 || message.length > 5000) {
errors.message = "Your message should be between 10 and 5,000 characters.";
}
return { data: { name, email, message }, errors };
}The email regex is intentionally loose. It confirms the value looks like an address without trying to implement the full email spec, which no regex does well. Whether the address actually receives mail is something you only learn by replying.
The line-break check is about header injection. The name and email values end up in the Reply-To and Subject headers. In older hand-rolled mailers, a value like Eve\r\nBcc: victim@example.com could add headers and turn your form into a spam relay. Nodemailer encodes header values safely, but a real name never contains a line break, so rejecting one costs nothing and keeps the guarantee from depending on a single library.
Write the Express route
// server.js
import express from "express";
import { rateLimit } from "express-rate-limit";
import { sendContactEmail, transporter } from "./mailer.js";
import { validateContact } from "./validate.js";
const app = express();
app.set("trust proxy", 1); // only if you run behind one reverse proxy (see below)
app.use(express.static("public"));
app.use(express.urlencoded({ extended: false, limit: "20kb" }));
app.use(express.json({ limit: "20kb" }));
const contactLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 5, // 5 submissions per IP per window
standardHeaders: "draft-8",
legacyHeaders: false,
message: { ok: false, error: "Too many messages. Please try again later." },
});
app.post("/api/contact", contactLimiter, async (req, res) => {
const body = req.body ?? {}; // Express 5 leaves req.body undefined if no parser matched
// Honeypot: real users never see this field, so anything in it is a bot.
if (body.company) {
return res.json({ ok: true }); // don't tell the bot it was caught
}
const { data, errors } = validateContact(body);
if (Object.keys(errors).length > 0) {
return res.status(422).json({ ok: false, errors });
}
await sendContactEmail(data); // Express 5 forwards a rejection to the error handler
res.json({ ok: true });
});Body size limits. The 20kb limit means a client can't make your server buffer megabytes of junk. A real contact message is a few kilobytes at most. Oversized requests get a 413 before your route runs.
The rate limiter. Five messages per IP every fifteen minutes is generous for a person and useless for a script. The limiter only wraps this route, so the rest of your app is unaffected. For the tradeoffs between fixed windows, sliding windows, and token buckets, see Rate limiting strategies for form endpoints.
trust proxy. The limiter keys on req.ip. Behind a reverse proxy or a platform load balancer, every request appears to come from the proxy's address unless Express trusts the X-Forwarded-For header, and then one bot can lock out all your visitors at once. Set trust proxy to the number of proxies in front of your app. If you're not behind a proxy, remove that line: trusting a header a client can set lets the client pick its own IP.
The honeypot. The route returns a normal success response when the hidden field is filled. A bot that gets an error might retry with different input; a bot that gets a success moves on.
Async errors. In Express 5, a rejected promise in a route handler goes to your error handler automatically. If you're on Express 4, wrap the handler body in try/catch and call next(err).
Now add the error handler and start the server:
// server.js (continued)
app.use((err, req, res, next) => {
// body-parser sets err.status (400 for malformed JSON, 413 for oversized bodies)
const status = err.status ?? 500;
if (status >= 500) console.error(err); // SMTP failures land here
res.status(status).json({
ok: false,
error:
status >= 500
? "We couldn't send your message. Please try again later."
: "That request couldn't be processed.",
});
});
await transporter.verify(); // fail at startup, not on the first real message
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Listening on http://localhost:${port}`));The handler logs the full error for you and returns a generic message to the visitor. SMTP errors can include hostnames and server responses that don't belong in a browser.
transporter.verify() connects and authenticates with your SMTP server before the app starts accepting traffic. A wrong password or blocked port stops the process at startup with a clear error, instead of surfacing on the first message someone sends you.
Build the form
Put this in public/index.html so express.static serves it:
<!-- public/index.html -->
<form id="contact-form" action="/api/contact" method="POST">
<label for="name">Name</label>
<input id="name" name="name" type="text" required autocomplete="name" />
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email" />
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required></textarea>
<!-- Honeypot: hidden from people, visible to bots -->
<input
name="company"
type="text"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
style="position: absolute; left: -9999px"
/>
<button type="submit">Send message</button>
<p id="form-status" role="status"></p>
</form>The honeypot is hidden with CSS rather than type="hidden", because bots tend to skip hidden inputs but fill anything that looks like a text field. tabindex="-1" and aria-hidden keep keyboard and screen reader users out of it. How to prevent spam in contact forms explains why this works and where it stops working.
Then the script that submits it:
<!-- public/index.html, before </body> -->
<script type="module">
const form = document.querySelector("#contact-form");
const statusEl = document.querySelector("#form-status");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
statusEl.textContent = "Sending…";
try {
const res = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
// URLSearchParams sends urlencoded data, which express.urlencoded() parses
body: new URLSearchParams(new FormData(form)),
});
const result = await res.json().catch(() => ({}));
if (res.ok) {
form.reset();
statusEl.textContent = "Thanks! Your message is on its way.";
} else if (res.status === 422 && result.errors) {
statusEl.textContent = Object.values(result.errors).join(" ");
} else {
statusEl.textContent =
result.error ?? "Something went wrong. Please try again.";
}
} catch {
statusEl.textContent =
"Network error. Check your connection and try again.";
} finally {
button.disabled = false;
}
});
</script>One detail here trips up a lot of people. Passing a FormData object straight to fetch() sends multipart/form-data, and neither express.urlencoded() nor express.json() parses multipart bodies. In Express 5, req.body is then undefined, which is why the route reads req.body ?? {}: without that guard, req.body.company throws and the visitor gets a 500. With it, every field fails validation, and nothing in the error points at the real cause. Wrapping the form data in URLSearchParams sends the same fields as application/x-www-form-urlencoded, which the middleware does parse.
The status element uses role="status" so screen readers announce "Sending…" and the result without moving focus. The button is disabled during the request so an impatient double-click doesn't send the message twice.
This server always responds with JSON, so the form depends on JavaScript. If
you need it to work without JavaScript, use req.accepts() in the route to
detect a regular form post and redirect it to a thank-you page instead.
Test it
Start the server with your environment file:
node --env-file=.env server.jsThen exercise the paths that matter, not only the happy one:
# A valid message: expect {"ok":true} and an email in your inbox
curl -X POST localhost:3000/api/contact \
-d "name=Jane Smith&email=jane@example.com&message=Testing the contact form."
# Invalid input: expect a 422 with field errors
curl -X POST localhost:3000/api/contact -d "name=&email=nope&message=hi"
# Run the first command five more times: the sixth request should get a 429Check that the email's Reply-To header shows the visitor's address and that hitting Reply in your mail client addresses the visitor, not yourself.
What this version still doesn't do
The code above is a sound foundation, but it's worth being clear about what it leaves out before you rely on it:
- Submissions aren't stored. If SMTP is down, the visitor sees an error and has to retry; nothing is saved for you to recover later.
- The rate limiter's memory store is per process. It resets on restart and isn't shared across multiple instances or serverless invocations.
- A honeypot stops simple bots only. Targeted spam needs content scoring or a challenge such as Cloudflare Turnstile.
- There's no record of what was sent, so you can't search past messages or notice when delivery quietly starts failing.
The first gap is the one that bites. Production systems store the submission first and send the notification second, so an email outage delays a message instead of losing it. A developer's guide to email notifications for form submissions walks through that pattern, along with deliverability basics like SPF and DKIM.
For the second, express-rate-limit supports shared stores such as Redis. If you deploy to a serverless platform, plan on one from the start.
Or skip the server entirely
If the contact form is the only reason you'd run a Node server, it's fair to ask whether you want to maintain one. Every item in the list above is something you'd build, deploy, and monitor yourself. Stop building your own form backend makes the longer case for when a service is the better trade.
With Formtorch, the same HTML form posts to a hosted endpoint instead of your own route:
<form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
<label for="name">Name</label>
<input id="name" name="name" type="text" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required></textarea>
<button type="submit">Send message</button>
</form>You get YOUR_FORM_ID by creating a form in the Formtorch dashboard; the endpoint URL appears as soon as the form exists. Submissions are stored before notification emails go out, every submission goes through server-side spam filtering, and the endpoint is rate limited per IP and per form. Cloudflare Turnstile and a built-in honeypot field are there when you want them. The free plan includes 150 submissions a month.
You can still keep your Node app for everything else. The form becomes one less thing it has to own.
Wrapping up
A Node.js contact form that sends email is a small amount of code, but the safe version makes a handful of deliberate choices: send from your own domain with the visitor in Reply-To, require TLS for SMTP, validate everything on the server, cap request sizes, rate limit by a correctly resolved IP, and verify the mail connection at startup.
If you're keeping the form on your own server, the next thing to add is storage, so a mail outage never costs you a message.
Rather not run a mail server?
Point your form at a Formtorch endpoint and get stored submissions, spam filtering, and email notifications without writing a backend.
Related posts

How file uploads work in HTML forms (and how to handle them)
