Next.js can send mail. The mistake is treating it like a form plugin: a client component, a copied Nodemailer snippet, and a from address that has never passed SPF or DKIM. Password resets and receipts are product events. They belong in a server route, behind an API key, on a domain you already authenticated.
This guide is the product-level pair to Send email in Laravel. For package install and a first request, use the ArawaMail Next.js quickstart.
Do not send until the domain is ready
The first successful 200 is not a deliverability test. If SPF, DKIM, and MAIL FROM are missing, the first real receipt teaches Gmail who you are — and it will remember the wrong lesson.
Before any App Router code:
- The sending domain is registered in ArawaMail and hosted on Cloudflare.
- Outbound sending is active, which writes DKIM and a custom MAIL FROM.
- The API key has the
sendingscope, and a domain-restricted key allows that same domain. - The
fromaddress uses that domain, not@gmail.comand notnoreply@localhost.
If you need the records explained first, read SPF, DKIM, and DMARC explained. Then send.
Keep the key on the server
Install the official client and put the key in .env.local:
npm install sdp-email-nextjs
SDP_EMAIL_KEY=your_api_key
Use the package only in route handlers, server actions, or server components. A key that reaches the browser is a sending identity you no longer control. Client components submit a form. The server sends the mail.
Never import sdp-email-nextjs in a Client Component
Never import sdp-email-nextjs in a file marked 'use client'. Client Components are bundled for the browser, so importing the SDK there risks exposing server-only code and encourages developers to pass secrets across the client boundary. Keep the SDK import and every call to emails.send() in a Route Handler, Server Action, or another server-only module.
A Client Component may collect an email address or button click, but it must submit that input to trusted server code. Never name the key NEXT_PUBLIC_SDP_EMAIL_KEY: variables with the NEXT_PUBLIC_ prefix are intentionally exposed to the browser.
Configure the key on Vercel
In Vercel, open the project’s Settings → Environment Variables and add SDP_EMAIL_KEY. Select the environments that should send mail—normally Preview and Production—and redeploy after adding or rotating the value. An existing deployment does not receive a newly added variable until it is redeployed.
- Use different restricted keys for Preview and Production when possible.
- Do not expose the key with a
NEXT_PUBLIC_prefix. - Keep
.env.localout of Git; use it only for local development. - Restrict each key to the sending domain used by that environment.
Nodemailer over SMTP is the wrong default on Vercel and most Node hosts: outbound 25/465 is often blocked, there is no durable queue, and you get none of the message id you will need when support asks whether the reset went out.
A route handler, not a page
Create app/api/email/receipt/route.ts. The handler should run after the order is committed, not while the checkout transaction is still open.
import { NextResponse } from 'next/server';
import { SdpEmail } from 'sdp-email-nextjs';
const sdp = new SdpEmail();
export async function POST(request: Request) {
const { orderId, email, name } = await request.json();
if (!orderId || !email) {
return NextResponse.json({ error: 'Missing order' }, { status: 400 });
}
const { data, error } = await sdp.emails.send({
from: 'Acme Billing <[email protected]>',
to: email,
subject: `Receipt for order ${orderId}`,
html:
`<p>Hi ${escapeHtml(name || 'there')},</p>` +
`<p>We received payment for order ${escapeHtml(orderId)}.</p>` +
`<p><a href="https://acme.com/orders/${encodeURIComponent(orderId)}">View receipt</a></p>`,
text:
`Hi ${name || 'there'},\n\n` +
`We received payment for order ${orderId}.\n` +
`View receipt: https://acme.com/orders/${orderId}\n`,
});
if (error) {
return NextResponse.json(
{ name: error.name, message: error.message },
{ status: error.statusCode || 500 }
);
}
return NextResponse.json({ id: data.id });
}
Send HTML and a plain-text part. Put the action in a real URL, not only in an image. Escape anything that came from the customer before it enters HTML.
Server Component example with a Server Action
A Server Component can own a Server Action and keep the SDK entirely on the server. Send from the action—not while the component renders—because React may render more than once.
import 'server-only';
import { SdpEmail } from 'sdp-email-nextjs';
const sdp = new SdpEmail();
export default function InvitePage() {
async function sendInvite(formData: FormData) {
'use server';
const email = String(formData.get('email') || '');
if (!email) return;
const { error } = await sdp.emails.send({
from: 'Acme Team <[email protected]>',
to: email,
subject: 'Your Acme invitation',
html: '<p>Your workspace invitation is ready.</p>',
text: 'Your workspace invitation is ready.',
});
if (error) throw new Error(error.message);
}
return (
<form action={sendInvite}>
<input name="email" type="email" required />
<button type="submit">Send invitation</button>
</form>
);
}
For a public form, authenticate or rate-limit the action and validate the recipient before sending. For order receipts and password resets, trigger the action from the trusted product event rather than accepting arbitrary message content from the browser.
Password reset: a token, not a vibe
A reset is not “an email.” It is a single-use secret with an expiry. Generate the token, store a hash, then send the link. Do not log the raw token. Do not put it only in the subject line.
import { NextResponse } from 'next/server';
import { SdpEmail } from 'sdp-email-nextjs';
const sdp = new SdpEmail();
export async function POST(request: Request) {
const { email, resetUrl } = await request.json();
const { data, error } = await sdp.emails.send({
from: 'Acme Accounts <[email protected]>',
to: email,
reply_to: '[email protected]',
subject: 'Reset your password',
html:
'<p>Reset your Acme password using this link. It expires in 30 minutes.</p>' +
`<p><a href="${resetUrl}">Reset password</a></p>` +
'<p>If you did not ask for this, you can ignore the message.</p>',
text:
'Reset your Acme password using this link. It expires in 30 minutes.\n\n' +
`${resetUrl}\n\n` +
'If you did not ask for this, you can ignore the message.\n',
});
if (error) {
return NextResponse.json(error, { status: error.statusCode || 500 });
}
return NextResponse.json({ id: data.id });
}
Reply-to should be a mailbox a human reads. “Reset your password” is a better subject than “Action required!!!” ArawaMail is built so that mailbox and the sending API can live on the same domain — support can answer the person who just received the reset.
Errors you should actually branch on
The client does not throw on API failures. It returns { data, error }. Treat that as the contract.
const { data, error } = await sdp.emails.send(payload);
if (error) {
// { statusCode, name, message }
}
| Signal | Typical cause | What to do |
|---|---|---|
401 / invalid_api_key | Missing or wrong SDP_EMAIL_KEY | Fail the deploy. Do not retry the user. |
403 | Domain not registered, sending inactive, or a restricted key | Fix DNS and sending before the next deploy. |
422 | Missing from, to, or both html and text | Fix the payload. Do not retry as-is. |
429 | Too many send attempts | Back off. Check you are not looping a job. |
statusCode: 0 / application_error | Network or timeout | Safe to retry if the send is idempotent. |
A 200 with an id means ArawaMail accepted the message. It does not mean the inbox has it yet. Persist that id next to the order or reset so support is not grepping Vercel logs.
Idempotency is your problem
Route handlers retry. Users double-click. A worker can crash after the API accepted the message. The send API does not make “one receipt per order” true by itself.
Record an application event before or with the send:
- Key:
receipt:order_1842orpassword_reset:user_91:token_version_3. - Store the provider message id when the call succeeds.
- If the row already exists, do not send again.
- If the call timed out and you have no id, look the event up before retrying, or accept that a timeout may need a retrieve-by-id once you have one.
You can retrieve a sent message later with GET /emails/{id} and read last_event. That is how you answer “did it go out?” without a second vendor console.
What this is not
This is not a Nodemailer tutorial, a marketing-broadcast recipe, or a reason to send mail from 'use client'. Transactional mail is the product talking. Keep it on your domain, on the server, with a message id you can find again.
When you need the category explained to the rest of the team, send them what transactional email is. When you need the first key and a copy-paste client, use the Next.js quickstart.
Create a free ArawaMail workspace, connect the domain, and send the first real message from the App Router. The first 3,000 transactional emails each month are free.