Framework guides
Send email from Next.js
Send transactional email from a Next.js App Router Route Handler or Server Action, without exposing your API key to the client.
This is the same POST /api/v1/emails endpoint documented in
Send a transactional email, wired into
the two places a Next.js App Router app actually calls it from: a Route
Handler and a Server Action.
Route Handler#
Good fit when the caller isn't a form on the same page — a webhook you relay, a cron job, a mobile client hitting your Next.js app as a backend.
// app/api/send-receipt/route.ts
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const { orderId, customerEmail } = await request.json();
const response = await fetch("https://mail.bytloop.com/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BYTLOOP_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `receipt-${orderId}`,
},
body: JSON.stringify({
from: "Support <support@send.acme.com>",
to: [customerEmail],
subject: `Your receipt for order #${orderId}`,
html: `<p>Thanks for the order! Order #${orderId} is confirmed.</p>`,
tags: { receipt_for_order: String(orderId) },
}),
});
if (!response.ok) {
const error = await response.json();
return NextResponse.json({ error: error.message }, { status: 502 });
}
const { id } = await response.json();
return NextResponse.json({ id });
}Server Action#
Good fit when the send is triggered by a form submission or a button in a Server Component tree — no separate API route to wire up.
// app/orders/[id]/actions.ts
"use server";
export async function sendReceipt(orderId: string, customerEmail: string) {
const response = await fetch("https://mail.bytloop.com/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BYTLOOP_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `receipt-${orderId}`,
},
body: JSON.stringify({
from: "Support <support@send.acme.com>",
to: [customerEmail],
subject: `Your receipt for order #${orderId}`,
html: `<p>Thanks for the order! Order #${orderId} is confirmed.</p>`,
tags: { receipt_for_order: String(orderId) },
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Send failed: ${error.message}`);
}
return response.json() as Promise<{ id: string }>;
}// app/orders/[id]/resend-button.tsx
"use client";
import { useTransition } from "react";
import { sendReceipt } from "./actions";
export function ResendButton({
orderId,
customerEmail,
}: {
orderId: string;
customerEmail: string;
}) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => startTransition(() => sendReceipt(orderId, customerEmail))}
>
{isPending ? "Sending…" : "Resend receipt"}
</button>
);
}Environment variable#
# .env.local — never prefix with NEXT_PUBLIC_
BYTLOOP_API_KEY=bm_live_xxxxxxxxxxxxxxxxCommon pitfalls#
- Using
NEXT_PUBLIC_BYTLOOP_API_KEY. Anything prefixedNEXT_PUBLIC_is inlined into the client bundle at build time. Use a plain env var name and only read it in a Route Handler, Server Action, or other server-only file. - No idempotency key on a Server Action. Server Actions re-run on
React's automatic retry after a network blip, which can double-send.
Pass a stable
Idempotency-Key(order id, invoice id — whatever is unique to the thing being emailed) exactly as in the examples above. - Edge runtime. If the Route Handler exports
export const runtime = "edge",process.envstill works for server-only vars set in your hosting provider's dashboard, but local.env.localvalues neednext dev(notnext dev --turboon very old Next versions had a known env-loading gap) — if the key comes backundefinedlocally, check it's actually loaded with aconsole.logbefore debugging the API call itself.
Next#
- Register a webhook endpoint — also a Next.js Route Handler — to hear about delivery, bounces, and opens.
- Read the rate limits page so you know how the platform behaves under bursts (e.g. a newsletter send from a cron job).