DEV Community

Hani Khanani
Hani Khanani

Posted on Originally published at ownmailserver.com

How to send email from Next.js with OwnMailServer

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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]!);
Enter fullscreen mode Exit fullscreen mode

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}`,
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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 },
});
Enter fullscreen mode Exit fullscreen mode

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.

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)