Send Transactional Email in Next.js App Router

Jonah Pierce

Send transactional email from Next.js App Router Route Handlers and Server Actions with one fetch — no SDK required.

Next.js App Router makes it easy to keep secrets on the server — which is exactly where email belongs. This guide walks through sending transactional email from a Route Handler and a Server Action using Notify’s HTTP API.

You will set up env vars, share one sendEmail helper, ship a complete welcome-email example, rehearse in sandbox, and avoid the pitfalls that turn a Route Handler into an open relay.

Never call Notify from a Client Component. NOTIFY_API_KEY stays server-side.

Prerequisites

Create a free Notify account at notify.cx. Free plan includes 1,000 emails/mo; Pro is $10 / 10,000; Scale is $50 / 100,000 — see pricing.

Environment setup

# .env.local
NOTIFY_API_KEY=your_api_key_here

Restart next dev after changing env files. Do not prefix the key with NEXT_PUBLIC_ — that would expose it to the browser.

Until your domain’s SPF/DKIM records propagate, use the sandbox endpoint documented below so you can still validate request shape and error handling. Full comparison: sandbox vs production.

Shared send helper

Put one helper in lib/ and reuse it from every auth, billing, and notification path.

// lib/notify.ts
type SendArgs = {
  to: string;
  subject: string;
  message: string;
  from?: string;
};

export async function sendEmail({
  to,
  subject,
  message,
  from = 'noreply@your-verified-domain.com'
}: SendArgs) {
  const apiKey = process.env.NOTIFY_API_KEY;
  if (!apiKey) {
    throw new Error('NOTIFY_API_KEY is not set');
  }

  const response = await fetch('https://notify.cx/api/email/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': apiKey
    },
    body: JSON.stringify({ from, to, subject, message })
  });

  if (!response.ok) {
    throw new Error(`Notify error: ${await response.text()}`);
  }

  return response.json();
}

message accepts plain text or HTML. Build templates as strings, React Email, or Markdown→HTML — Notify delivers whatever you pass; it does not own a template studio.

Option A — Route Handler (complete example)

Use this when another service (or your own client after session auth) needs an HTTP endpoint.

// app/api/email/welcome/route.ts
import { sendEmail } from '@/lib/notify';

/**
 * In production, require a session and only email the signed-in user
 * (or a hard-coded transactional template). Never accept arbitrary
 * `to` + HTML from an anonymous client.
 */
export async function POST(request: Request) {
  const body = await request.json();
  const email = typeof body.email === 'string' ? body.email.trim() : '';
  const name = typeof body.name === 'string' ? body.name.trim() : '';

  if (!email) {
    return Response.json({ error: 'email is required' }, { status: 400 });
  }

  // Example auth gate — replace with your session helper:
  // const session = await getSession(request);
  // if (!session || session.email !== email) {
  //   return Response.json({ error: 'Unauthorized' }, { status: 401 });
  // }

  try {
    const data = await sendEmail({
      to: email,
      subject: 'Welcome',
      message: `
        <h1>Welcome${name ? `, ${name}` : ''}</h1>
        <p>Thanks for joining. <a href="https://yourapp.com/dashboard">Open your dashboard</a>.</p>
      `
    });
    return Response.json({ ok: true, data });
  } catch (error) {
    console.error(error);
    return Response.json({ error: 'Failed to send email' }, { status: 500 });
  }
}

Call it from a trusted server context:

curl -X POST http://localhost:3000/api/email/welcome \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","name":"Ada"}'

Option B — Server Action (complete example)

Server Actions keep the send on the server without exposing a public JSON API.

// app/actions/send-welcome.ts
'use server';

import { sendEmail } from '@/lib/notify';

export async function sendWelcomeEmail(formData: FormData) {
  const email = String(formData.get('email') ?? '').trim();
  if (!email) return { error: 'email required' };

  // In production: ensure the caller is allowed to email this address
  // (e.g. the signed-in user’s own email immediately after signup).

  await sendEmail({
    to: email,
    subject: 'Welcome',
    message: `<h1>Welcome</h1><p>Thanks for joining.</p>`
  });

  return { ok: true };
}
// app/welcome/page.tsx
import { sendWelcomeEmail } from '@/app/actions/send-welcome';

export default function WelcomePage() {
  return (
    <form action={sendWelcomeEmail}>
      <input type="email" name="email" required />
      <button type="submit">Send welcome email</button>
    </form>
  );
}

For a focused recipe, see Next.js Server Action + Notify.

Sandbox while you develop

Before your domain is verified, rehearse the API shape without pretending you are in production:

// lib/notify-sandbox.ts
export async function sendEmailSandbox(opts: {
  to: string;
  subject: string;
  message: string;
}) {
  const response = await fetch('https://notify.cx/api/email/send/test', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': process.env.NOTIFY_API_KEY!
    },
    body: JSON.stringify({
      from: 'noreply@your-verified-domain.com',
      ...opts
    })
  });

  if (!response.ok) {
    throw new Error(await response.text());
  }

  return response.json();
}

Flip to https://notify.cx/api/email/send once domain verification is green.

Real product flows

Wire the same helper into auth and billing — Notify stays a thin delivery layer:

Use caseGuide
Password resetPassword reset emails
Magic link / OTPMagic link & OTP
Stripe receiptStripe receipt recipe

Common pitfalls

  1. Importing sendEmail into a Client Component — the bundler may leak the key path; keep helpers in server-only modules.
  2. Public open relay — a Route Handler that accepts arbitrary to / HTML without auth will get abused.
  3. Unverified from — production rejects or spam-folders messages until SPF/DKIM are set.
  4. Silent failures — always check response.ok and surface errors to logs.
  5. Mixing marketing blasts on the transactional domain — keep newsletters on a separate subdomain/tool.

Next steps

  1. Verify your sending domain
  2. Add a shared sendEmail helper (this guide)
  3. Ship password reset or welcome with hashed tokens in your DB
  4. When volume grows, enable webhooks on Pro/Scale

Canonical stack docs: How to use Notify with Next.js.

Resources