DEV Community

Nic Polotnianko πŸ‡ΊπŸ‡¦
Nic Polotnianko πŸ‡ΊπŸ‡¦

Posted on Edited on

How to send transactional emails from Next.js App Router (Server Actions, route handlers and after())

Almost every Next.js app needs to send a few emails: a welcome email after signup, a magic link or password reset, a receipt. The App Router gives you several places to put that code, and they don't behave the same way when something is slow or fails.

This guide covers where the send should live, how to keep the API key off the client, how to avoid making users wait for your email provider, and the mistakes that lead to missing or duplicate emails. The examples use a generic sendEmail() helper so you can plug in whichever provider you use.

1. Keep the send on the server, always

Your email provider's API key can send mail as your domain. If it reaches the browser, anyone can use it.

  • Read it from process.env.EMAIL_API_KEY (no NEXT_PUBLIC_ prefix, which would inline it into client bundles).
  • Put the helper in a module that only server code imports, and mark it so a mistaken client import fails the build:
// lib/email.ts
import "server-only";

type SendEmailInput = {
  to: string;
  subject: string;
  html: string;
  idempotencyKey?: string;
};

export async function sendEmail(input: SendEmailInput) {
  const res = await fetch("https://api.your-email-provider.com/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.EMAIL_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(input),
  });

  if (!res.ok) {
    throw new Error(`Email send failed: ${res.status} ${await res.text()}`);
  }
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

The server-only package (npm i server-only) makes the build fail if a Client Component imports this file.

2. Pick the right entry point

Trigger Where to send from
A form in your own UI (signup, invite a teammate, contact form) A Server Action
An external system calls you (Stripe webhook, auth provider hook, cron) A route handler (app/api/.../route.ts)
Something that happens later (trial ending in 3 days, weekly digest) A queue or scheduled job, not a request handler

Server Action: invite a teammate

// app/team/actions.ts
"use server";

import { after } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { sendEmail } from "@/lib/email";
import { renderInviteEmail } from "@/emails/invite";

export async function inviteTeammate(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error("Not signed in");

  const email = String(formData.get("email") ?? "").trim().toLowerCase();
  if (!email.includes("@")) return { error: "Enter a valid email" };

  const invite = await db.invite.create({
    data: { email, teamId: session.teamId, invitedBy: session.userId },
  });

  // Respond to the user now; send the email after the response is done.
  after(async () => {
    await sendEmail({
      to: email,
      subject: `${session.name} invited you to ${session.teamName}`,
      html: await renderInviteEmail({ invite, inviter: session.name }),
      idempotencyKey: `invite-${invite.id}`,
    });
  });

  return { ok: true };
}
Enter fullscreen mode Exit fullscreen mode

Two things to notice:

  • Authorization happens inside the action. Server Actions are public HTTP endpoints. Anyone can call them with any form data, so check the session and validate input in the action itself, not only in the UI.
  • after() (from next/server, stable since Next.js 15.1) runs the callback once the response has been sent. The user doesn't wait on your email provider's latency, and a slow provider doesn't hold the form in a pending state.

Route handler: a webhook from your auth provider

// app/api/webhooks/auth/route.ts
import { sendEmail } from "@/lib/email";
import { renderWelcomeEmail } from "@/emails/welcome";
import { verifyAuthWebhook } from "@/lib/auth-webhooks";

export async function POST(request: Request) {
  const payload = await verifyAuthWebhook(request); // throws on a bad signature
  if (payload.type !== "user.created") {
    return Response.json({ ignored: true });
  }

  await sendEmail({
    to: payload.user.email,
    subject: "Welcome to Acme",
    html: await renderWelcomeEmail({ name: payload.user.firstName }),
    idempotencyKey: `welcome-${payload.user.id}`,
  });

  return Response.json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode

Webhook senders retry when you return an error or time out, which is good for reliability and bad for duplicate emails. That's what the idempotencyKey is for (section 5).

3. Don't send from Client Components or useEffect

It's tempting to fire a "send welcome email" request from the client after signup succeeds. Don't:

  • The browser tab can close before the request goes out.
  • React Strict Mode runs effects twice in development, so you'll see double emails locally and might "fix" the wrong thing.
  • You need a public endpoint for it, which anyone can call to make you send email.

Send from the server code that created the user, or from the webhook your auth provider calls.

4. Templates: React Email or plain HTML

Email clients are not browsers. Outlook on Windows still renders with Word's engine, Gmail strips <style> blocks in some contexts, and flexbox and grid are unreliable. Two sane options:

  • React Email (@react-email/components + @react-email/render): write templates as React components using email-safe primitives (Section, Row, Column, Button) and render them to an HTML string on the server.
  • Provider-side templates: design the email in your provider's editor and send a template ID plus variables. Non-developers can then edit copy without a deploy.
// emails/welcome.tsx
import { Html, Body, Container, Heading, Text, Button } from "@react-email/components";
import { render } from "@react-email/render";

function WelcomeEmail({ name }: { name: string }) {
  return (
    <Html>
      <Body style={{ fontFamily: "Arial, sans-serif", backgroundColor: "#ffffff" }}>
        <Container style={{ maxWidth: 560, padding: 24 }}>
          <Heading as="h1">Welcome, {name}</Heading>
          <Text>Your workspace is ready. The fastest way to get value is to connect your data first.</Text>
          <Button href="https://app.acme.com/onboarding" style={{ backgroundColor: "#111", color: "#fff", padding: "12px 20px" }}>
            Finish setup
          </Button>
        </Container>
      </Body>
    </Html>
  );
}

export function renderWelcomeEmail(props: { name: string }) {
  return render(<WelcomeEmail {...props} />); // returns a Promise<string>
}
Enter fullscreen mode Exit fullscreen mode

Whichever you choose, also send a plain-text version if your provider doesn't generate one, and keep the important action in a real link rather than only in an image.

5. Make sends idempotent

Duplicates come from retries: a webhook delivered twice, a user double-submitting a form, a serverless function retried after a timeout. The fix is to give every logical email a stable key:

  • welcome-${userId}, invite-${inviteId}, receipt-${invoiceId}, password-reset-${tokenId}

Then either pass it to your provider as an idempotency key (many APIs accept one and return the original result for a repeated key), or record it in your database with a unique constraint and skip the send when the insert conflicts:

const inserted = await db.sentEmail.createMany({
  data: [{ key: `welcome-${user.id}` }],
  skipDuplicates: true,
});
if (inserted.count === 0) return; // already sent
Enter fullscreen mode Exit fullscreen mode

6. Know what after() doesn't do

after() is great for "don't make the user wait". It is not a job queue:

  • If the send throws, nothing retries it. Log the error, and alert on it for important emails.
  • On serverless platforms the work is tied to the lifetime of that invocation. Short sends are fine; long work belongs elsewhere.
  • It can't schedule anything for later.

For emails that must arrive (password resets, receipts), either retry inside the callback with a short backoff, or enqueue a job (BullMQ, Inngest, QStash, Trigger.dev, your provider's own scheduling) and let the queue retry. For anything time-based (trial reminders, onboarding drips), use a scheduler or an email platform's automation instead of request handlers.

7. Before you go live

  • [ ] Send from your own domain with SPF, DKIM and DMARC set up, not the provider's shared test domain
  • [ ] Separate sending subdomains for transactional and marketing email, if you send both
  • [ ] API key only in server env vars, server-only on the email module
  • [ ] Server Actions check the session and validate input
  • [ ] Every email has an idempotency key
  • [ ] Failures are logged and alerted, not swallowed
  • [ ] Test rendering in Gmail, Outlook and Apple Mail (or a rendering preview tool)

Using Sequenzy for this

Here is how this looks with Sequenzy, an email platform for SaaS. The sendEmail helper above becomes:

import "server-only";
import Sequenzy from "sequenzy";

const sequenzy = new Sequenzy({ apiKey: process.env.SEQUENZY_API_KEY });

export function sendWelcome(to: string, firstName: string) {
  return sequenzy.transactional.send({
    to,
    slug: "welcome-email", // template designed in the dashboard
    variables: { firstName },
  });
}
Enter fullscreen mode Exit fullscreen mode

The same account also runs the onboarding and trial sequences from section 6, so time-based emails don't need their own queue. The quick start takes about five minutes. Everything else in this guide applies whichever provider you choose.

Top comments (0)