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
- Next.js 13+ App Router project (
app/directory) - Node 18+ (global
fetch) - API key from Notify Credentials
- Verified domain for production
from(domain verification)
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 case | Guide |
|---|---|
| Password reset | Password reset emails |
| Magic link / OTP | Magic link & OTP |
| Stripe receipt | Stripe receipt recipe |
Common pitfalls
- Importing
sendEmailinto a Client Component — the bundler may leak the key path; keep helpers in server-only modules. - Public open relay — a Route Handler that accepts arbitrary
to/ HTML without auth will get abused. - Unverified
from— production rejects or spam-folders messages until SPF/DKIM are set. - Silent failures — always check
response.okand surface errors to logs. - Mixing marketing blasts on the transactional domain — keep newsletters on a separate subdomain/tool.
Next steps
- Verify your sending domain
- Add a shared
sendEmailhelper (this guide) - Ship password reset or welcome with hashed tokens in your DB
- When volume grows, enable webhooks on Pro/Scale
Canonical stack docs: How to use Notify with Next.js.
Comments
Loading comments…