A developer's guide to email notifications for form submissions

The feature request is simple: when someone submits the contact form, send me an email.
Implementing it correctly is less simple. Email delivery is one of the more deceptively tricky parts of web infrastructure. Sending an email is easy. Sending an email reliably, from a domain that doesn't get flagged as spam, with graceful handling when delivery fails, is a different project.
This guide walks through the full picture: the two types of notification email, how to choose a delivery mechanism, the fundamentals of deliverability, how to handle failures, and where a form backend fits into all of it.
Two kinds of email, two different jobs
Most form notification setups involve two distinct types of email. Mixing them up causes problems.
Notification emails go to you (or your team). They say: "A new submission came in. Here's what it says." These are internal alerts. Their job is to make sure you know about the submission promptly.
Confirmation emails (also called autoresponders) go to the person who submitted the form. They say: "Thanks, we got your message." Their job is to reassure the submitter that their message was received and set expectations for a response.
Both serve real purposes. But they have different requirements, different risks, and different rules. Keep them separate in your code and your mental model.
Why not just use SMTP directly?
The instinct is often to configure your server to send email directly via SMTP. It's technically straightforward, and there's no third-party dependency.
The problem is deliverability. Email providers (Gmail, Outlook, Yahoo, and others) use a combination of signals to decide whether an incoming email is legitimate or spam. IP reputation is a major one.
When you send email from a fresh VPS or serverless function, you're sending from an IP with no history. New IPs with no track record are frequently blocked or sent straight to spam. Building a good sending reputation takes months of consistent volume and low bounce rates. Most developers don't have that.
Transactional email providers have already built that reputation. Resend, Postmark, SendGrid, Mailgun, and similar services maintain sending infrastructure with excellent deliverability. When you use their API, your emails arrive in inboxes because they've spent years earning the right to.
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
async function sendNotification(submission: {
name: string;
email: string;
message: string;
}) {
await resend.emails.send({
from: "notifications@yoursite.com",
to: "you@yoursite.com",
subject: `New message from ${submission.name}`,
html: `
<p><strong>From:</strong> ${submission.name} (${submission.email})</p>
<p><strong>Message:</strong></p>
<p>${submission.message}</p>
`,
});
}Escape user-submitted content before inserting it into email HTML.
submission.message above should go through an HTML escape function before
being placed in the template. Most email libraries don't do this
automatically.
The basics of email deliverability
Even when using a reputable provider, a few configuration steps make a meaningful difference in whether your notification emails land in the inbox.
Send from a domain you own. notifications@yoursite.com is trusted. notifications@resend.dev is less trusted because it's shared infrastructure. Most transactional email providers let you add a custom sending domain with a few DNS records.
Set up SPF. SPF (Sender Policy Framework) is a DNS record that declares which mail servers are allowed to send email for your domain. Without it, receiving servers have no way to verify the email actually came from you.
Set up DKIM. DKIM (DomainKeys Identified Mail) adds a cryptographic signature to outgoing messages that receiving servers can verify. Your email provider gives you the DNS records to add; the verification happens automatically after that.
Most providers walk you through both during domain setup. It's a one-time configuration that takes about ten minutes and significantly improves deliverability.
Use a consistent from address. Changing your sending address frequently hurts reputation. Pick one and stick with it.
Store submissions before sending notifications
This ordering matters and gets skipped more often than it should.
If you send the notification email first and then store the submission, a delivery failure means the email never went out and you have no record of what was submitted. The submission is lost.
The correct order is: store first, notify second. The submission record is the source of truth. The email is a layer on top.
export async function POST(req: Request) {
const data = await req.formData();
// 1. Validate
const error = validate(data);
if (error) return Response.json({ error }, { status: 400 });
// 2. Store (this is your source of truth)
const submission = await db
.insert(submissions)
.values({
name: String(data.get("name")),
email: String(data.get("email")),
message: String(data.get("message")),
createdAt: new Date(),
deliveryStatus: "pending",
})
.returning();
// 3. Notify (failure here doesn't lose the submission)
try {
await sendNotification(submission[0]);
await db
.update(submissions)
.set({ deliveryStatus: "delivered" })
.where(eq(submissions.id, submission[0].id));
} catch (err) {
console.error("Notification failed:", err);
await db
.update(submissions)
.set({ deliveryStatus: "failed" })
.where(eq(submissions.id, submission[0].id));
// Don't re-throw: the submission is stored, just not notified
}
return Response.json({ ok: true });
}When delivery fails, you want to know about it (log it, track the status) without it blocking the response to the submitter or losing their data.
Handling delivery failures
Email delivery can fail for reasons outside your control: the provider is down, the receiving server rejects the message, the API rate limit kicks in. You need a plan for each scenario.
Short-term failures: if your notification didn't send, you still have the submission in your database. You can resend manually from your dashboard, or implement a retry mechanism with exponential backoff.
Status tracking: recording the delivery status on each submission (pending, delivered, failed) gives you a way to audit notifications and resend ones that failed.
Monitoring: if notifications are silently failing in production, you want to know. A daily check of how many submissions have deliveryStatus = "failed" is a simple sanity check. Some providers also offer webhooks for delivery events.
The practical safety net is your submission database. If email delivery were to fail completely, you'd still have every submission stored. That's the argument for never using email as your only storage mechanism.
Verified recipients: why you can't just email any address
If your form settings let users configure a notification email address, you can't send notifications to arbitrary addresses without verification.
Here's the risk: if someone sets their notification email to victim@example.com, your platform becomes a tool for sending unsolicited emails to a third party. This is spam, it can trigger abuse reports against your sending domain, and it damages your deliverability for everyone using the platform.
The solution is email verification: before a notification email address receives any messages, send a confirmation link to that address. Only send notifications after the address has been confirmed.
// When a user adds a notification email:
await db.insert(pendingRecipients).values({
formId,
email,
token: generateSecureToken(),
expiresAt: addHours(new Date(), 24),
});
await sendVerificationEmail(email, token);
// Notifications don't start until the token is confirmedThis is also good practice for your own email address. Verifying it once at setup confirms that your notifications actually work before you're waiting on a real submission.
Confirmation emails to submitters
If you want to send a confirmation email to the person who submitted the form, there are additional rules to follow.
The submitter entered their email address as part of the form. You haven't obtained their permission to send marketing email. The confirmation email should be transactional only: acknowledging receipt of their specific message, and nothing more.
Including unsubscribe links in confirmation emails is legally required in some jurisdictions (CAN-SPAM, GDPR) even for transactional messages. It's good practice regardless.
await resend.emails.send({
from: "contact@yoursite.com",
to: submission.email,
subject: "We received your message",
html: `
<p>Hi ${submission.name},</p>
<p>Thanks for reaching out. We'll get back to you within 1-2 business days.</p>
<p>The Yoursite Team</p>
<p style="font-size: 12px; color: #999;">
You received this because you submitted a form at yoursite.com.
</p>
`,
});Keep it simple. A confirmation email that acknowledges the submission and sets a response time expectation is everything this type of email needs to do.
Testing notification emails
Before shipping, test the complete path: submit the form, verify the notification appears in your inbox, verify the spam folder is empty, check the from name and address look correct, and confirm the submission content is displayed clearly.
- Notification arrives in inbox, not spam folder.
- From address is your custom domain, not a provider domain.
- From name is recognizable (not a raw email address).
- Submission content is correct and readable.
- Subject line identifies it as a form notification.
- Submission is stored in the database before email is sent.
- Delivery failure is logged and tracked, not silently swallowed.
The spam folder check is worth doing in a fresh email account or using a tool like Mail Tester, which grades your sending configuration and flags common deliverability issues.
Using a form backend
If you're using a hosted form backend like Formtorch, notification email is built in. You add a verified recipient address in your form settings, confirm it, and notifications start arriving. The provider handles the transactional email API, deliverability, and delivery failure logging.
For most contact forms, this is the right tradeoff. The engineering involved in doing it yourself is mapped out in The hidden complexity of handling form submissions. For setting up the form itself, see the HTML contact form guide or the Next.js contact form guide.
Need form notifications without the setup?
Add a verified email in Formtorch and start receiving notifications immediately.

