For the complete site index, see llms.txt. Docs index: llms-docs.txt. Marketing corpus: llms-full.txt. Docs corpus: llms-full-docs.txt. Prefer markdown URLs where available (append .md).. Product skill: skill.md. Pricing: pricing.md. Docs MCP: /docs/mcp. Site MCP: /mcp.

Tutorials/

How to Send Email in Next.js App Router (2026)

12 MINUTES READ

How to Send Email in Next.js App Router (2026)
Summary

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

Approach
Best for
File shape
Server Action
Forms, signup, password reset, any UI in the same Next app
app/actions/send-email.ts + client form
Route Handler (API)
Webhooks, mobile clients, external services, cron, AI tools
app/api/send-email/route.ts

Both 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

  1. A Next.js app on the App Router (this guide uses app/, not pages/)
  2. A Reloop account and API key from the dashboard (keys look like rl_…)
  3. 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-email

Package: reloop-email · source: reloop-labs/reloop-node


2. Add the API key (server only)

Create or edit .env.local in the project root:

env
RELOOP_API_KEY=rl_your_api_key_here

Rules that matter:

  • Use RELOOP_API_KEY (no NEXT_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.

lib/reloop.ts
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-written fetch

Action

app/actions/send-welcome-email.ts
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.
  • from must use a domain you verified in Reloop.

Client form that calls the action

app/signup/welcome-form.tsx
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 <form
12 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.ok
24 ? "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:

app/contact/page.tsx
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

app/api/send-email/route.ts
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

typescript
1// From a Client Component or any HTTP client
2const 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:

bash
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:

  1. Require a session / auth check for user-triggered sends
  2. Or require a shared secret header for webhooks and cron
  3. Rate-limit by IP or user id

Example secret gate for internal callers:

typescript
1// inside POST, before sending
2const 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

Question
Prefer Server Action
Prefer Route Handler
Triggered by a form in this Next app?
Yes
Need a public HTTP URL for webhooks / mobile / cron?
Yes
Want zero client fetch boilerplate?
Yes
Called from non-Next code?
Yes
Same Reloop SDK?
Yes
Yes
Safe for RELOOP_API_KEY?
Yes (server)
Yes (server)

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

typescript
1const { response, emailError } = await reloop.mail.send({
2 from: "Acme <hello@mail.yourdomain.com>", // required — verified domain
3 to: "user@example.com", // string or string[]
4 subject: "Order confirmed", // required
5 html: "<p>Your order shipped.</p>", // HTML body
6 text: "Your order shipped.", // plain-text fallback
7 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 fetch instead of relying on Node-only modules — same idea as the Cloudflare Workers guide.
typescript
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

  1. Push the app and import it in Vercel
  2. Add env vars: RELOOP_API_KEY (and INTERNAL_SEND_SECRET if you use it)
  3. Redeploy so server functions pick up the vars
  4. Send a test from production; confirm delivery in the Reloop logs

Details: Vercel + Reloop.


10. Common mistakes

  1. NEXT_PUBLIC_RELOOP_API_KEY — exposes the key. Use server-only RELOOP_API_KEY.
  2. Calling Reloop from a Client Component — keys and secrets belong in Server Actions / Route Handlers only.
  3. Unverified from domain — verify DNS first (domain guide).
  4. Open /api/send-email — always authenticate or use a shared secret.
  5. Wrong constructor — use new Reloop({ apiKey }), not a bare string, for the current SDK.
  6. Ignoring emailError — check it (or catch throws) and return a safe user message.
  7. HTML only — include text as 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.localRELOOP_API_KEY=rl_… (no NEXT_PUBLIC_)
  • lib/reloop.ts shared 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

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.

Reloop