Sending Email from Next.js with the Rovela API
Two complete patterns for sending email from Next.js App Router: a Route Handler for external callers and a Server Action for form submissions, both using fetch against the Rovela REST API with server-only protection and idempotency keys.

Sending email from a Next.js app comes down to one constraint: the API key must stay on the server. App Router gives you two places for server-side code: Route Handlers and Server Actions. Both examples below call Rovela's REST API directly with fetch — no SDK to install, copy-paste ready.
Setup
- Add and verify your sending domain in the Rovela dashboard.
- Under API Keys, create a key with the
sending_accessscope. Production only needs this one permission. - Add to
.env.local:
ROVELA_API_KEY=re_your_api_key
MAIL_FROM="Acme <hello@yourdomain.com>"
Do not add a NEXT_PUBLIC_ prefix. Variables with that prefix are bundled into client-side code and become visible to anyone who opens DevTools.
Shared send function
Wrap the API call in a module that can only run on the server:
// lib/mail.ts
import 'server-only'
type SendInput = {
to: string | string[]
subject: string
html: string
text?: string
idempotencyKey?: string
}
export async function sendEmail({ idempotencyKey, ...input }: SendInput) {
const res = await fetch('https://api.rovela.dev/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
'Content-Type': 'application/json',
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
body: JSON.stringify({ from: process.env.MAIL_FROM, ...input }),
cache: 'no-store',
})
const body = await res.json()
if (!res.ok) {
// Error responses look like {"name": "...", "message": "..."}
throw new Error(`Rovela ${res.status}: ${body.message ?? 'send failed'}`)
}
return body as { object: 'email'; id: string }
}
Two things worth noting. import 'server-only' causes the build to fail loudly if a client component ever imports this file, rather than silently bundling your key into the frontend. The Idempotency-Key header ensures that the same key used within 24 hours produces only one sent message, so a network timeout and retry won't duplicate the email — run npm i server-only first.
Option 1: Route Handler
Good for external callers or when the frontend submits with fetch:
// app/api/contact/route.ts
import { sendEmail } from '@/lib/mail'
export async function POST(req: Request) {
const { email, message } = await req.json()
if (typeof email !== 'string' || !email.includes('@') || typeof message !== 'string') {
return Response.json({ error: 'invalid input' }, { status: 400 })
}
try {
const { id } = await sendEmail({
to: 'support@yourdomain.com',
subject: `Message from ${email}`,
text: message,
html: `<p>${escapeHtml(message)}</p>`,
})
return Response.json({ id })
} catch (err) {
console.error(err)
return Response.json({ error: 'send failed' }, { status: 502 })
}
}
function escapeHtml(s: string) {
return s.replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!,
)
}
User input must be escaped before it goes into HTML. Without it, someone who submits <a href="phishing-site"> will see that rendered in your inbox.
Option 2: Server Action
The form submits directly to a server function, no endpoint needed:
// app/signup/actions.ts
'use server'
import { randomInt } from 'node:crypto'
import { sendEmail } from '@/lib/mail'
export async function sendCode(formData: FormData) {
const email = String(formData.get('email') ?? '')
const code = randomInt(100000, 1000000).toString()
// store a hash of code + expiry in the database here
await sendEmail({
to: email,
subject: `Your verification code: ${code}`,
html: `<p>Your code is <strong>${code}</strong>. It expires in 10 minutes.</p>`,
text: `Your code is ${code}. It expires in 10 minutes.`,
idempotencyKey: `signup-code:${email}:${Math.floor(Date.now() / 60000)}`,
})
}
// app/signup/page.tsx
import { sendCode } from './actions'
export default function Page() {
return (
<form action={sendCode}>
<input name="email" type="email" required />
<button type="submit">Send code</button>
</form>
)
}
Use crypto.randomInt for the code, not Math.random — Math.random is not cryptographically secure. The minute bucket in the idempotency key means rapid repeated submissions in the same minute produce one message; the next minute produces a fresh one.
Add rate limiting
Email submission endpoints attract abuse: someone can use your form to send messages to arbitrary addresses, burning your sending quota and hurting your domain reputation. At a minimum, rate-limit by both IP and recipient address — for example, one message per address per minute, five per hour.
Deploying
Set ROVELA_API_KEY and MAIL_FROM in your platform's environment variables. The HTTP API runs on port 443, which cloud providers don't block the way they block outbound SMTP port 25.
Further reading: Sending email API reference, Idempotency keys: preventing duplicate sends on retry, Verification code example.
The free tier includes 100 messages per month — enough for development and testing. Get your API key.