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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 the retry-after header, or send up to 100 emails in one POST /emails/batch request.
  • 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_exceeded until 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.