How to send email from a serverless function with Resend
When someone fills in my contact form, I want the message to land in my inbox.
There’s no mail server to run for that — I use Resend, a
transactional email API, from inside my Vercel serverless function. This post
walks through the whole send, straight from api/contact.ts. It assumes the
request body has already been validated with the Zod schema shared between client and server and passed the in-memory rate limiter.
Step 1: keep secrets in environment variables
The Resend API key is a secret — it must never be hard-coded or shipped to the browser. I read it, and the addresses, from environment variables:
// api/contact.ts
const apiKey = process.env.RESEND_API_KEY
const to = process.env.CONTACT_TO_EMAIL
const from = process.env.CONTACT_FROM_EMAIL ?? "onboarding@resend.dev"
if (!apiKey || !to) {
console.error(
"Contact form is misconfigured: missing RESEND_API_KEY or CONTACT_TO_EMAIL",
)
return res
.status(500)
.json({ ok: false, error: "Server is not configured to send mail." })
}process.env is how a Node serverless function reads env vars (you set these in
your Vercel project settings). The from address falls back to Resend’s shared
sandbox sender, onboarding@resend.dev, which is handy before you’ve verified
your own domain. If the key or the recipient is missing, I fail fast with a 500
instead of trying to send and crashing.
Step 2: create the client and send
Resend’s SDK is a class you instantiate with the API key, then call
emails.send:
// api/contact.ts
const resend = new Resend(apiKey)
const { error } = await resend.emails.send({
from: `Portfolio Contact <${from}>`,
to: [to],
replyTo: email,
subject: `New portfolio message from ${name}`,
text: `From: ${name} <${email}>\n\n${message}`,
html: `
<div style="font-family: system-ui, sans-serif; line-height: 1.6;">
<h2 style="margin:0 0 12px;">New portfolio message</h2>
<p><strong>Name:</strong> ${escapeHtml(name)}</p>
<p><strong>Email:</strong> ${escapeHtml(email)}</p>
<p style="white-space: pre-wrap;"><strong>Message:</strong><br/>${escapeHtml(
message,
)}</p>
</div>`,
})The interesting fields:
to: [to]— Resend takes an array of recipients.replyTo: email— this is the trick that makes the form useful. The email arrives from my portfolio sender, but hitting “Reply” addresses the actual person who wrote in.textandhtml— I provide both a plain-text and an HTML version; clients pick whichever they can render.
Step 3: escape the user’s input
The name, email, and message come from a stranger on the internet, and
I’m dropping them into an HTML email. Injecting raw user text into HTML is an
injection risk, so I escape it first:
// api/contact.ts
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'")
}This converts characters that have special meaning in HTML (<, >, &,
quotes) into their harmless entity form, so a message containing <script> is
displayed as text rather than executed. The plain-text text field doesn’t need
this — text isn’t parsed as markup.
Step 4: handle failures
emails.send returns an object with an error property rather than throwing on
a rejected send, so I check it and also wrap everything in a try/catch for
unexpected crashes:
// api/contact.ts
if (error) {
console.error("Resend error:", error)
return res.status(502).json({
ok: false,
error: "Failed to send message. Please email me directly.",
})
}
return res.status(200).json({ ok: true })A 502 signals “the upstream mail service failed,” while the surrounding
try/catch returns a 500 for anything truly unexpected. On success I return the
tidy { ok: true } the client is looking for.
What to remember
- Read the Resend API key from
process.env, never hard-code it; fail fast if it’s missing. - Instantiate with
new Resend(apiKey)and callresend.emails.send(...). - Set
replyToto the sender’s email so replies reach the real person. - Escape any user-supplied text before putting it in HTML.
- Check the returned
errorand pick sensible status codes (502upstream,500unexpected).
Related reading: mock this send in a test so no real mail goes out — see unit-testing a form and serverless handler with Vitest. The same Resend client also powers the notification step in auto-generating and publishing blog posts with a GitHub Action.