To send email from Next.js, call an email API from server code: a route handler or a Server Action, never a client component. With OwnMailServer that's one fetch to POST https://api.ownmailserver.com/emails, with your API key read from an environment variable. If your app already uses Resend's SDK or React Email, keep the code: set RESEND_BASE_URL=https://api.ownmailserver.com and swap in our API key.
| Your situation | Use | New packages |
|---|---|---|
| Starting fresh |
fetch to POST /emails
|
None |
| Already on Resend's SDK | The same SDK with RESEND_BASE_URL
|
None |
| Want templates as React components | React Email, with either of the above | @react-email/components |
| A library that only speaks SMTP | Nodemailer to smtp.ownmailserver.com:587
|
nodemailer |
The examples use the App Router. The same calls work in the Pages Router's API routes.
Step 1: Add your API key
You need an approved OwnMailServer account with a verified sending domain: the from address must be on it, or the API returns 403. Create an API key in your dashboard under API keys & SMTP. Choose Sending only (the default), limited to your sending domain: every call in this guide only sends. Then add it to .env.local:
OMS_API_KEY=mp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Don't prefix it with NEXT_PUBLIC_. Next.js inlines NEXT_PUBLIC_ variables into the JavaScript it sends to browsers, and even a sending-only key can send email as your domain. Add the same variable in your host's settings for production, such as Vercel's Environment Variables.
To make a mistake fail at build time rather than leak, put the send helper in a file that imports the server-only package:
// lib/email.ts
import "server-only";
type Email = { to: string | string[]; subject: string; html: string; text?: string; replyTo?: string };
export async function sendEmail(email: Email, idempotencyKey?: string) {
const res = await fetch("https://api.ownmailserver.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OMS_API_KEY}`,
"Content-Type": "application/json",
...(idempotencyKey && { "Idempotency-Key": idempotencyKey }),
},
body: JSON.stringify({
from: "Acme <hello@acme.com>",
to: email.to,
subject: email.subject,
html: email.html,
text: email.text,
reply_to: email.replyTo,
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.name}: ${body.message}`);
return body.id as string;
}
If a client component imports this file, the build fails instead of shipping your key.
Step 2: Send from a route handler
A route handler is the right place for anything another service calls, or a form that posts JSON:
// app/api/contact/route.ts
import { sendEmail } from "@/lib/email";
export async function POST(request: Request) {
const { name, email, message } = await request.json();
if (typeof email !== "string" || !email.includes("@") || typeof message !== "string" || message.length > 5000) {
return Response.json({ error: "Invalid input" }, { status: 400 });
}
await sendEmail({
to: "sales@acme.com",
replyTo: email,
subject: `New enquiry from ${String(name).slice(0, 100)}`,
html: `<p>${escapeHtml(message)}</p>`,
text: message,
});
return Response.json({ ok: true });
}
const escapeHtml = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
The form emails you, with the visitor's address in reply_to, so pressing Reply answers them. Never send to an address the visitor types, or from it: an open form that emails anyone is a spam relay, and a visitor's address in from is refused because it isn't your verified domain.
Step 3: Or send from a Server Action
For a form inside your app, a Server Action skips the API route:
// app/invite/actions.ts
"use server";
import { sendEmail } from "@/lib/email";
import { auth } from "@/lib/auth"; // your own session helper
export async function sendInvite(formData: FormData) {
const user = await auth();
if (!user) throw new Error("Not signed in");
const to = String(formData.get("email"));
const inviteId = crypto.randomUUID(); // in a real app, the id of the invite row you just saved
await sendEmail(
{ to, subject: `${user.name} invited you to Acme`, html: `<p><a href="https://acme.com/invite/${inviteId}">Accept the invite</a></p>` },
`invite-${inviteId}`,
);
}
// app/invite/page.tsx
import { sendInvite } from "./actions";
export default function Page() {
return (
<form action={sendInvite}>
<input name="email" type="email" required />
<button type="submit">Send invite</button>
</form>
);
}
Server Actions are public endpoints that anyone can call, so check the session inside the action, as above, not only in the page.
The Idempotency-Key makes retries safe. If the same key arrives again within 24 hours, our API returns the first response and sends nothing, so a double-click, a retried request or a re-run job can't email someone twice. Use something tied to the event, like the invite or order id.
Using Resend's SDK or React Email
Our API follows Resend's request shapes, response shapes and errors, and we test Resend's own Node.js SDK, version 6, against it. If your app already uses it, change the environment, not the code:
RESEND_API_KEY=mp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # an OwnMailServer key
RESEND_BASE_URL=https://api.ownmailserver.com
React Email templates keep working. When you pass react, the SDK renders the component to HTML on your server and sends the HTML, so nothing depends on Resend's servers:
// app/api/welcome/route.tsx (JSX, so .tsx)
import { Resend } from "resend";
import { WelcomeEmail } from "@/emails/welcome";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: Request) {
const { email, name, userId } = await request.json();
const { data, error } = await resend.emails.send(
{ from: "Acme <hello@acme.com>", to: [email], subject: "Welcome to Acme", react: <WelcomeEmail name={name} /> },
{ idempotencyKey: `welcome-${userId}` },
);
if (error) return Response.json({ error }, { status: 500 });
return Response.json({ id: data.id });
}
Rendering needs @react-email/components (or @react-email/render) installed. Without the SDK, call React Email's render() yourself and pass the HTML to sendEmail() from Step 1. The full switch, including webhooks and domains, is in moving from Resend.
SMTP with Nodemailer
If a library you can't change only speaks SMTP, Nodemailer works in the Node.js runtime:
import nodemailer from "nodemailer";
const transport = nodemailer.createTransport({
host: "smtp.ownmailserver.com",
port: 587,
secure: false, // starts plain, then upgrades with STARTTLS
requireTLS: true,
auth: { user: "ownmailserver", pass: process.env.OMS_API_KEY },
});
On serverless hosts, prefer the API. Every function call would open a new SMTP connection and log in, and an error comes back as an SMTP reply code instead of JSON. One HTTPS request is quicker and easier to debug.
Limits and errors to handle
-
10 requests a second per account. A burst gets
429 rate_limit_exceeded; wait for the seconds in theretry-afterheader, or send up to 100 emails in onePOST /emails/batchrequest. -
The warm-up cap. A new account's daily limit starts at a few hundred recipients and grows after each clean day (how warm-up works). Above it, sends get
429 daily_quota_exceededuntil the last 24 hours have room again. Move your most important email first, and keep the old provider for bulk sends until the cap is high enough. -
Suppressed addresses. We don't send to addresses that hard-bounced or complained. The email is stored as
suppressed, so check its status or use webhooks if your app needs to know. -
Accepted isn't delivered. A success response means we queued the email. To act on deliveries and bounces, add a webhook or look the email up with
GET /emails/{id}.
When this isn't the right fit
If your app sends a few hundred emails a month, Resend's free tier, 3,000 emails a month as of October 2026, may be all you need, and Resend's developer experience is excellent. We suit apps that want their own IP once they pass 200,000 emails a month, managed warm-up and monitoring, and a Resend-compatible API, so switching back and forth is a change of environment variables. Sending through us costs $0.20 per 1,000 emails, with a $10 monthly minimum that covers the first 50,000.
FAQ
How do I send email from Next.js?
Call an email API from server code. In the App Router, that's a route handler such as app/api/contact/route.ts or a Server Action marked use server. Read the API key from an environment variable without the NEXT_PUBLIC_ prefix, so Next.js never sends it to the browser, and make one POST request to the provider's send endpoint. The response tells you the email was accepted.
Can I use Resend's SDK with OwnMailServer?
Yes. Our API follows Resend's request and response shapes, and we test Resend's own Node.js SDK, version 6, against it. Set RESEND_BASE_URL=https://api.ownmailserver.com, or pass baseUrl when you create the client, and use an OwnMailServer API key. Sending, batches, attachments, scheduling and idempotency keys all work.
Does React Email work without Resend?
Yes. React Email is an open-source library that turns React components into email HTML. Call its render function and send the HTML to any provider. If you use Resend's SDK with the react option, the SDK does that rendering on your server before it sends, so the same code works when it points at our API.
Should I use Nodemailer and SMTP in Next.js?
You can, in the Node.js runtime. On serverless hosts such as Vercel, the HTTPS API is usually simpler: each email is one request, with nothing to keep open between function calls, and errors come back as JSON. Use SMTP when a library you can't change only speaks SMTP. Our SMTP server is smtp.ownmailserver.com on port 587.
How do I stop a contact form from being abused?
Validate the input on the server, send the message to yourself rather than to an address the visitor types, put the visitor's address in reply_to, and rate-limit the route by IP. Add a CAPTCHA or a hidden honeypot field if bots find it. An open form that emails any address you're given will be found and used for spam.
Originally published at ownmailserver.com.
Top comments (0)