Send email from Next.js
A Route Handler or Server Action, one fetch, an environment variable on Vercel. The same request shape as Resend, so code an assistant wrote for Resend needs a URL and a key changed.
Updated September 15, 2026
Mail leaves a Next.js app from the server — a Route Handler, a Server Action or a route in the Pages API — never from a component. The key lives in an environment variable, the request is one fetch, and the shape of that request is the one Resend uses, so an assistant that knows Resend's format has already written most of it.
A Server Action
// app/actions/send-receipt.ts
"use server";
export async function sendReceipt(to: string, orderNumber: string) {
const response = await fetch("https://pigeonatlas.com/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PIGEONATLAS_API_KEY}`,
"Content-Type": "application/json",
// A retried action must not send twice.
"Idempotency-Key": `receipt-${orderNumber}`,
},
body: JSON.stringify({
from: "Your Shop <hello@mail.yourshop.com>",
to: [to],
subject: `Receipt for order ${orderNumber}`,
html: `<p>Thanks — order ${orderNumber} is confirmed.</p>`,
text: `Thanks — order ${orderNumber} is confirmed.`,
}),
});
if (!response.ok) {
throw new Error(`Email failed: ${response.status} ${await response.text()}`);
}
return (await response.json()).id as string;
}
Call it from a form or after a database write. The returned id is what to store against the order if you want to answer "did they get it?" later.
A Route Handler
The same call from app/api/send/route.ts, for when something outside the app — a webhook from your payment provider, say — should trigger the message:
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const { to, subject, html } = await request.json();
const response = await fetch("https://pigeonatlas.com/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PIGEONATLAS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ from: "Your Shop <hello@mail.yourshop.com>", to: [to], subject, html }),
});
return NextResponse.json(await response.json(), { status: response.status });
}
Protect this route. An unauthenticated handler that sends whatever it is posted is an open relay under your domain, and the first person to find it will use it.
The environment variable
On Vercel: Project → Settings → Environment Variables, add PIGEONATLAS_API_KEY for Production and Preview. Do not prefix it NEXT_PUBLIC_ — that ships the key to the browser. Locally it goes in .env.local, which Next.js reads and git ignores.
If the code was written for Resend
Change the URL to https://pigeonatlas.com/v1/emails and the key. The request and response shapes match. If it used the Resend SDK rather than fetch, replace the call with the fetch above — the SDK pins its own host and cannot be pointed here, and it is one fewer dependency.
Edge runtime
Both examples run on the Node runtime and the Edge runtime unchanged: it is fetch, nothing else. What you cannot do on the Edge runtime is SMTP, which is why there is no nodemailer in this guide.
Before it works
Add mail.yourshop.com in Pigeon Atlas, publish the DKIM, SPF and MX records it shows you, and wait for the tick beside each. Until then a send from that domain is refused with 403 validation_error, and the message says which record is missing. The API reference covers attachments, scheduling, the delivery webhook and the 402 you get when the month's allowance is gone.
A thousand a month free, no card
Enough to verify a domain and run a small app from it.