← Back to blog

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.

Updated October 15, 2026
Sending Email from Next.js with the Rovela API

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

  1. Add and verify your sending domain in the Rovela dashboard.
  2. Under API Keys, create a key with the sending_access scope. Production only needs this one permission.
  3. 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) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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.