
Send transactional email from Next.js App Router with Reloop — complete Server Action and Route Handler examples, env setup, error handling, and Vercel deploy.
To send email in Next.js App Router, call Reloop only on the server — from a Server Action or a Route Handler. Never put your API key in a Client Component or public env var.
This guide covers both patterns end-to-end: install, shared client, form + Server Action, API route + fetch, error handling, and deploy on Vercel.
What you will build
app/actions/send-email.ts + client formapp/api/send-email/route.tsBoth run on the server. Both use the same Reloop SDK call. Pick Server Actions for app UI. Pick Route Handlers when something outside your React tree needs HTTP.
Prerequisites
- A Next.js app on the App Router (this guide uses
app/, notpages/) - A Reloop account and API key from the dashboard (keys look like
rl_…) - A verified sending domain — see connect a domain and SPF / DKIM / DMARC
Optional but useful: the Node.js SDK docs and full send mail API.
1. Install the SDK
npm install reloop-emailPackage: reloop-email · source: reloop-labs/reloop-node
2. Add the API key (server only)
Create or edit .env.local in the project root:
RELOOP_API_KEY=rl_your_api_key_hereRules that matter:
- Use
RELOOP_API_KEY(noNEXT_PUBLIC_prefix). Public vars are bundled into the browser. - Do not commit
.env.local. Keep it in.gitignore. - On Vercel, add the same key under Project → Settings → Environment Variables (see Vercel integration).
3. Create a shared Reloop client
One client file keeps Server Actions and Route Handlers identical.
1import { Reloop } from "reloop-email";2
3if (!process.env.RELOOP_API_KEY) {4 throw new Error("Missing RELOOP_API_KEY");5}6
7export const reloop = new Reloop({8 apiKey: process.env.RELOOP_API_KEY,9});Initialize with an object: { apiKey: string }. Optional: baseUrl if you self-host Reloop.
4. Method A — Server Action (forms & app UI)
When to use this
- Contact form, welcome email after signup, magic link, password reset
- Call site is a React Server Component or Client Component in this app
- You want progressive enhancement with
<form action={...}>and no hand-writtenfetch
Action
1"use server";2
3import { reloop } from "@/lib/reloop";4
5export type SendWelcomeResult =6 | { ok: true; messageId?: string }7 | { ok: false; error: string };8
9export async function sendWelcomeEmail(10 email: string,11 name?: string,12): Promise<SendWelcomeResult> {13 const to = email.trim().toLowerCase();14
15 if (!to || !to.includes("@")) {16 return { ok: false, error: "Enter a valid email address." };17 }18
19 try {20 const { response, emailError } = await reloop.mail.send({21 from: "Acme <onboarding@mail.yourdomain.com>",22 to,23 subject: name ? `Welcome, ${name}` : "Welcome to Acme",24 html: `<p>Thanks for signing up${name ? `, ${name}` : ""}.</p>25 <p>You're ready to go.</p>`,26 text: `Thanks for signing up${name ? `, ${name}` : ""}. You're ready to go.`,27 reply_to: "support@yourdomain.com",28 tags: [{ name: "type", value: "welcome" }],29 });30
31 if (emailError) {32 console.error("Reloop send failed", emailError);33 return { ok: false, error: "Could not send email. Try again." };34 }35
36 return { ok: true, messageId: response?.messageId };37 } catch (err) {38 console.error("Reloop send threw", err);39 return { ok: false, error: "Could not send email. Try again." };40 }41}Notes:
"use server"must be at the top of the file (or above the exported function).- Return a safe result to the client. Log Reloop details server-side; do not dump API errors into the UI.
frommust use a domain you verified in Reloop.
Client form that calls the action
1"use client";2
3import { useState, useTransition } from "react";4import { sendWelcomeEmail } from "@/app/actions/send-welcome-email";5
6export function WelcomeForm() {7 const [message, setMessage] = useState<string | null>(null);8 const [pending, startTransition] = useTransition();9
10 return (11 <form12 className="space-y-3"13 onSubmit={(e) => {14 e.preventDefault();15 const form = e.currentTarget;16 const data = new FormData(form);17 const email = String(data.get("email") ?? "");18 const name = String(data.get("name") ?? "");19
20 startTransition(async () => {21 const result = await sendWelcomeEmail(email, name || undefined);22 setMessage(23 result.ok24 ? "Check your inbox for a welcome email."25 : result.error,26 );27 if (result.ok) form.reset();28 });29 }}30 >31 <input name="name" type="text" placeholder="Name" />32 <input name="email" type="email" required placeholder="you@company.com" />33 <button type="submit" disabled={pending}>34 {pending ? "Sending…" : "Create account"}35 </button>36 {message ? <p>{message}</p> : null}37 </form>38 );39}Form Action without client JS (optional)
You can also bind the action directly:
1import { sendWelcomeEmail } from "@/app/actions/send-welcome-email";2
3async function contactAction(formData: FormData) {4 "use server";5 const email = String(formData.get("email") ?? "");6 await sendWelcomeEmail(email);7}8
9export default function ContactPage() {10 return (11 <form action={contactAction}>12 <input name="email" type="email" required />13 <button type="submit">Send</button>14 </form>15 );16}Server Actions work for same-origin app flows. For external HTTP callers, use a Route Handler.
5. Method B — Route Handler (API route)
When to use this
- Stripe / Clerk / Supabase webhooks that should fire an email
- Mobile apps, desktop clients, or another backend posting JSON
- Cron jobs, n8n, Zapier, or AI agents that call HTTP
- You need a stable URL like
POST /api/send-email
Route Handler
1import { NextResponse } from "next/server";2import { reloop } from "@/lib/reloop";3
4type Body = {5 to?: string;6 subject?: string;7 html?: string;8 text?: string;9 name?: string;10};11
12export async function POST(request: Request) {13 let body: Body;14
15 try {16 body = (await request.json()) as Body;17 } catch {18 return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });19 }20
21 const to = body.to?.trim().toLowerCase();22 if (!to || !to.includes("@")) {23 return NextResponse.json(24 { error: "Field `to` must be a valid email" },25 { status: 400 },26 );27 }28
29 const subject = body.subject?.trim() || "Hello from Next.js";30 const html =31 body.html?.trim() ||32 `<p>Hi${body.name ? ` ${body.name}` : ""},</p><p>This was sent from a Next.js Route Handler.</p>`;33 const text =34 body.text?.trim() ||35 `Hi${body.name ? ` ${body.name}` : ""}, This was sent from a Next.js Route Handler.`;36
37 try {38 const { response, emailError } = await reloop.mail.send({39 from: "Acme <hello@mail.yourdomain.com>",40 to,41 subject,42 html,43 text,44 reply_to: "support@yourdomain.com",45 tags: [{ name: "source", value: "api-route" }],46 });47
48 if (emailError) {49 console.error("Reloop send failed", emailError);50 return NextResponse.json(51 { error: "Failed to send email" },52 { status: 502 },53 );54 }55
56 return NextResponse.json({57 ok: true,58 id: response?.id,59 messageId: response?.messageId,60 });61 } catch (err) {62 console.error("Reloop send threw", err);63 return NextResponse.json(64 { error: "Failed to send email" },65 { status: 500 },66 );67 }68}Call it from the browser or another service
1// From a Client Component or any HTTP client2const res = await fetch("/api/send-email", {3 method: "POST",4 headers: { "Content-Type": "application/json" },5 body: JSON.stringify({6 to: "user@example.com",7 name: "Ada",8 subject: "Your receipt",9 html: "<p>Thanks for your purchase.</p>",10 text: "Thanks for your purchase.",11 }),12});13
14const data = await res.json();15if (!res.ok) throw new Error(data.error ?? "Send failed");cURL for local testing:
1curl -X POST http://localhost:3000/api/send-email \2 -H "Content-Type: application/json" \3 -d '{"to":"you@example.com","name":"Ada","subject":"Test from Next.js"}'Protect the route
An open send endpoint is an open relay. At minimum:
- Require a session / auth check for user-triggered sends
- Or require a shared secret header for webhooks and cron
- Rate-limit by IP or user id
Example secret gate for internal callers:
1// inside POST, before sending2const secret = request.headers.get("x-internal-secret");3if (secret !== process.env.INTERNAL_SEND_SECRET) {4 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });5}Add INTERNAL_SEND_SECRET to .env.local and Vercel the same way as the API key.
6. Server Action vs Route Handler — pick one
fetch boilerplate?RELOOP_API_KEY?You can use both in one app: Server Actions for product UI, Route Handlers for integrations.
7. Full mail.send fields you will use most
1const { response, emailError } = await reloop.mail.send({2 from: "Acme <hello@mail.yourdomain.com>", // required — verified domain3 to: "user@example.com", // string or string[]4 subject: "Order confirmed", // required5 html: "<p>Your order shipped.</p>", // HTML body6 text: "Your order shipped.", // plain-text fallback7 reply_to: "support@yourdomain.com",8 cc: "ops@yourdomain.com",9 bcc: "archive@yourdomain.com",10 tags: [{ name: "type", value: "receipt" }],11});More options (attachments, templates, scheduling, headers) are on the send email API reference.
Always send both html and text when you can — better deliverability and clients that block HTML still get a readable message. See transactional email best practices.
8. Runtime: Node vs Edge
- Prefer the default Node.js runtime for the Reloop SDK.
- Edge Runtime is a restricted environment. If you must run on Edge, call Reloop’s HTTP API with
fetchinstead of relying on Node-only modules — same idea as the Cloudflare Workers guide.
1// Edge-friendly alternative (no SDK)2export const runtime = "edge";3
4export async function POST(request: Request) {5 const body = await request.json();6
7 const res = await fetch("https://reloop.sh/api/mail/v1/send", {8 method: "POST",9 headers: {10 "Content-Type": "application/json",11 "x-api-key": process.env.RELOOP_API_KEY!,12 },13 body: JSON.stringify({14 from: "Acme <hello@mail.yourdomain.com>",15 to: body.to,16 subject: body.subject,17 html: body.html,18 text: body.text,19 }),20 });21
22 const data = await res.json();23 return Response.json(data, { status: res.status });24}For most App Router apps on Vercel, stick with the SDK on Node.
9. Deploy on Vercel
- Push the app and import it in Vercel
- Add env vars:
RELOOP_API_KEY(andINTERNAL_SEND_SECRETif you use it) - Redeploy so server functions pick up the vars
- Send a test from production; confirm delivery in the Reloop logs
Details: Vercel + Reloop.
10. Common mistakes
NEXT_PUBLIC_RELOOP_API_KEY— exposes the key. Use server-onlyRELOOP_API_KEY.- Calling Reloop from a Client Component — keys and secrets belong in Server Actions / Route Handlers only.
- Unverified
fromdomain — verify DNS first (domain guide). - Open
/api/send-email— always authenticate or use a shared secret. - Wrong constructor — use
new Reloop({ apiKey }), not a bare string, for the current SDK. - Ignoring
emailError— check it (or catch throws) and return a safe user message. - HTML only — include
textas a fallback.
11. FAQ
Not directly. Client Components can call a Server Action or fetch your
Route Handler. The Reloop SDK and API key stay on the server.
Server Action. Less plumbing, works with <form action>, and stays
same-origin by default.
In the server-side callback or server action that creates the user, call
sendWelcomeEmail(user.email). Do not wait for the client to “remember” to
hit an API.
Yes. Render your React Email template to HTML on the server, then pass the
string as html to reloop.mail.send.
In the Reloop dashboard logs, and via webhooks for delivered, bounced, opened, and clicked events.
12. Copy-paste checklist
-
npm install reloop-email -
.env.local→RELOOP_API_KEY=rl_…(noNEXT_PUBLIC_) -
lib/reloop.tsshared client - UI path:
app/actions/send-welcome-email.ts+ form - HTTP path:
app/api/send-email/route.ts+ auth/secret - Verified sending domain on
from - Vercel env vars set and redeployed
- Test send + check logs
Next steps
- Transactional email best practices — templates, timing, deliverability
- Next.js example in docs — short SDK reference
- Send mail API — full payload
- Vercel AI SDK + Reloop — tool-calling notifications
- Sibling guides: SvelteKit · Remix · Cloudflare Workers
Read more

How to Check if an Email is Disposable (Free Temp Email Checker)
In this guide, you'll learn what disposable emails are, why they matter, and how you can quickly check if an email is disposable.

Why We Open-Sourced Our Email Infrastructure
The story behind Reloop: why we built an open-source alternative to SendGrid, Resend, and Mailgun, why email infrastructure is too critical to rent forever, and what we're changing.

IP Warming Schedule Guide: How to Build Sender Reputation (2026)
A comprehensive, practical guide to warming new sending IP addresses — with an exact week-by-week volume ramp schedule, recipient segmentation strategy, throttle limits, and deliverability monitoring.

Why Your Emails Land In Spam & How to Fix It
The definitive guide to diagnosing and fixing email spam folder placement — covering authentication, content, reputation, list hygiene, and ISP-specific issues.
Ship your first email with Reloop in minutes
Open-source, deliverability-focused, and yours to self-host or run on Reloop Cloud. No lock-in, no rewrite later.