Developer Guides

Send transactional email from Next.js

Send transactional email from Next.js: App Router route handler, env API key, product-domain From, receipts, password resets, errors, and idempotency.

Published Last updated
ArawaMail developer guide graphic: Send transactional email from Next.js

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 sending scope, and a domain-restricted key allows that same domain.
  • The from address uses that domain, not @gmail.com and not noreply@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.local out 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 }
}
SignalTypical causeWhat to do
401 / invalid_api_keyMissing or wrong SDP_EMAIL_KEYFail the deploy. Do not retry the user.
403Domain not registered, sending inactive, or a restricted keyFix DNS and sending before the next deploy.
422Missing from, to, or both html and textFix the payload. Do not retry as-is.
429Too many send attemptsBack off. Check you are not looping a job.
statusCode: 0 / application_errorNetwork or timeoutSafe 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_1842 or password_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.

Simple, transparent plans

Start free. Grow when your email does.

Get one domain, API access and 3,000 transactional emails every month at no cost.

Compare plans