Send Email from Next.js: Route Handler, Server Action and Key Safety
Send email from Next.js with one fetch call: a Route Handler for a contact form, a Server Action for signups, where the key lives, and the errors to handle.
Next.js runs your code in two places, and only one of them may hold an email API key. Anything that sends mail has to run on the server: a Route Handler under app/api, a Server Action, or a Server Component at render time. The send itself is a single fetch call with no SDK. Here is the contact-form version, the signup version, and the handful of things that break it.
Quick answer
Put the key in .env.local as OQUMAIL_API_KEY (no NEXT_PUBLIC_ prefix, ever), read it with process.env inside a Route Handler or Server Action, and POST JSON to the send endpoint with to as an array, a subject, and html or text. The From address is fixed by the mailbox the key was created on; the body only takes an optional fromName.
Where the key lives
- In the OquMail dashboard create an API key on the mailbox you want to send from, for example hello@yourdomain.com, and copy it once.
- Locally: add OQUMAIL_API_KEY=oqm_live_... to .env.local. Next.js loads it on the server only. Add .env.local to .gitignore if it is not already.
- On Vercel, Netlify or your own server: add the same variable in the project environment settings for Production and Preview.
- Never prefix it with NEXT_PUBLIC_. That prefix inlines the value into the browser bundle, and anyone can then send mail as you.
A Route Handler for a contact form
This handles POST /api/contact from a form on your site and emails the message to your team. The visitor's address goes in the body text because the API does not take a Reply-To header, so whoever answers copies it into a new message or replies to the thread with the address visible:
// app/api/contact/route.js
export async function POST(req) {
const { name, email, message, website } = await req.json();
if (website) return Response.json({ ok: true }); // honeypot field: bots fill it, people do not
if (!email || !message || message.length > 5000) {
return Response.json({ error: "invalid" }, { status: 400 });
}
const res = await fetch("https://api.oqumail.com/api/v1/emails", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.OQUMAIL_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({
to: ["team@yourdomain.com"],
subject: "Website enquiry from " + name,
text: "From: " + name + " <" + email + ">\n\n" + message,
fromName: "Website form"
})
});
if (!res.ok) {
console.error("send failed", res.status, await res.text());
return Response.json({ error: "send_failed" }, { status: 502 });
}
return Response.json({ ok: true });
}A Server Action for a signup email
When the send belongs to a form your own app renders, a Server Action keeps it in one file with no API route. The function runs on the server; the form calls it directly:
// app/signup/actions.js
"use server";
export async function sendWelcome(formData) {
const email = String(formData.get("email") || "").trim();
if (!email) return { ok: false };
const res = await fetch("https://api.oqumail.com/api/v1/emails", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.OQUMAIL_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({
to: [email],
subject: "Welcome to Acme",
html: "<p>Thanks for signing up. Reply to this email if you get stuck.</p>",
text: "Thanks for signing up. Reply to this email if you get stuck."
})
});
return { ok: res.ok };
}Free business email on your own domain
OquMail gives you up to 15 mailboxes on your domain — free — with guided SPF/DKIM/DMARC, webmail, IMAP/SMTP for any mail app, and a send API. Setup is a few DNS records; changes usually take effect within minutes, occasionally up to 48 hours. Start at oqumail.com.
Read the response, not just the status
A successful send returns 202 Accepted with a JSON body containing an id, a messageId and status "queued". Log the id next to your own record (the order, the signup) so a "they never got the email" ticket can be matched to the delivery log in the dashboard. The failures you will actually see:
- 400: the body failed validation. The usual cause is to as a string instead of an array, or a missing subject and body.
- 401: the key is wrong, was revoked, or the environment variable is not set in that deployment. Preview deployments on Vercel are the classic case.
- 403: the key's mailbox or workspace is suspended, or the mailbox reached its daily send limit. The message in the body says which.
- 422: the message was blocked by content screening. The body carries a reason you can show in your own logs.
- 429: you hit the per-minute or daily limit for the key. Read the Retry-After header and queue the send rather than retrying in a loop.
Pitfalls specific to Next.js
- Calling fetch to the API from a Client Component. The key is undefined there, and if you "fix" that with NEXT_PUBLIC_ you have leaked it.
- Forgetting that Route Handlers are public URLs. Add the honeypot above, rate limit by IP, or put Turnstile or hCaptcha in front of a contact form.
- Awaiting the send inside a Server Component render. It works, but a slow send blocks the page; send from an action or a handler instead.
- Caching. A Route Handler that only sends is dynamic by nature; if you export const dynamic = "force-static" anywhere in that route, the send will not run on each request.
- Edge runtime is fine. fetch is available, no Node-only library is needed, so export const runtime = "edge" works for the handler above.
Common questions
What about the Pages Router?
Identical, in pages/API (a way for your app or website to send email automatically)/contact.js with the (req, res) signature. Read req.body, make the same fetch call, and respond with res.status(200).json({ ok: true }).
Can I set the From address per send?
No. The From address is the mailbox the key belongs to, which is what keeps DKIM alignment correct. Create a second key on billing@ or alerts@ if you need a different sender, and choose the key per use case. The display name is yours to set with fromName.
How do I test without emailing real people?
Send to a mailbox on your own domain while developing. Every send, including test sends, appears in the delivery log with the recipient server's response, so you can confirm the message left correctly before pointing it at users.