All posts
4 min readPigeonify

How to send email from Next.js

Send email from Next.js Route Handlers and Server Actions without leaking your API key to the browser, and without blocking the response.

nextjsreactjavascripttutorial

The hard part of sending email from Next.js is not the sending. It is making sure the key never reaches the browser, and that a slow provider does not become a slow page.

Before you start

You need three things, and they take about five minutes:

  1. An account. Sign up free — 5,000 emails a month, no card.
  2. A verified sending domain. Add your domain in the dashboard and publish the DKIM and SPF records it gives you. You cannot send from a domain you have not proved you control, and neither can anyone pretending to be you — that is the whole point. If the terms are unfamiliar, SPF, DKIM and DMARC explained covers them properly.
  3. An API key. Create one under API Keys. Treat it like a password: it can send mail as you.

Put the key in your environment rather than your source. Next.js reads it from there in every example below.

Keep the key on the server

This is the mistake that matters:

// WRONG — NEXT_PUBLIC_ puts this in the JavaScript bundle,
// readable by anyone who opens devtools.
const key = process.env.NEXT_PUBLIC_PIGEONIFY_API_KEY

An API key that can send email as your domain, shipped to every visitor. Name it without the prefix and read it only in server code:

const key = process.env.PIGEONIFY_API_KEY

If you want the compiler to enforce it, put the client in a file that starts with import "server-only". Importing it from a client component then fails the build rather than leaking at runtime.

A Route Handler

// app/api/contact/route.ts
import { Pigeonify } from "pigeonify"
import { NextResponse } from "next/server"

const pigeonify = new Pigeonify(process.env.PIGEONIFY_API_KEY)

export async function POST(request: Request) {
  const { email, message } = await request.json()

  if (!email?.includes("@") || !message?.trim()) {
    return NextResponse.json({ error: "Email and message are required" }, { status: 400 })
  }

  try {
    await pigeonify.emails.send({
      from: "forms@yourdomain.com",
      to: "you@yourdomain.com",
      replyTo: email,
      subject: `Contact form from ${email}`,
      html: `<p>${escapeHtml(message)}</p>`,
    })
    return NextResponse.json({ ok: true })
  } catch (error) {
    console.error("[contact] send failed", error)
    return NextResponse.json({ error: "Could not send your message" }, { status: 500 })
  }
}

Two details worth copying. from is your domain, replyTo is theirs — sending from a visitor's address fails SPF and gets the message filtered, which is why so many contact forms quietly stop working. And escape anything a user typed before putting it in HTML.

A Server Action

"use server"

import { Pigeonify } from "pigeonify"

const pigeonify = new Pigeonify(process.env.PIGEONIFY_API_KEY)

export async function subscribe(formData: FormData) {
  const email = String(formData.get("email") ?? "")
  if (!email.includes("@")) return { error: "Enter a valid email address" }

  await pigeonify.emails.send({
    from: "hello@yourdomain.com",
    to: email,
    subject: "Thanks for subscribing",
    html: "<p>You're on the list.</p>",
  })

  return { ok: true }
}

A Server Action is a public endpoint. Validate its input exactly as you would a Route Handler's, and rate limit it — an unprotected "email this address" action is an open relay for someone else's spam.

The serverless trap

On Vercel and similar platforms, work started but not awaited may be killed when the response is sent. This looks like it works and silently drops mail under load:

// WRONG on serverless — the function may be frozen before this finishes.
pigeonify.emails.send(message)
return NextResponse.json({ ok: true })

Either await it, or use waitUntil so the platform keeps the function alive:

import { waitUntil } from "@vercel/functions"

waitUntil(pigeonify.emails.send(message).catch(console.error))
return NextResponse.json({ ok: true })

Rendering the HTML

React components make good email templates, as long as what you render is table-based and inline-styled. react-dom/server turns one into a string:

import { renderToStaticMarkup } from "react-dom/server"

const html = renderToStaticMarkup(<WelcomeEmail name={user.name} />)

Use renderToStaticMarkup, not renderToString — the latter adds React hydration attributes that are dead weight in an inbox.

What to get right before production

Do not send from a request handler and wait. An HTTP call to any email provider can take a second or more, and a slow provider becomes a slow checkout page. Queue the send and return.

Handle the failure. A send can fail because the address is wrong, because you are rate limited, or because the provider is having a bad minute. The first is permanent and the other two are not — retrying a dead address forever is how a sender's reputation degrades. See how to handle bounces.

Use a subdomain. Send transactional mail from mail.yourdomain.com rather than the domain that carries your business correspondence. If sending reputation is ever damaged, the damage stays contained.

Never send to an address that has bounced or unsubscribed. Suppression should be automatic, not a thing you remember. Pigeonify suppresses permanent bounces and complaints for you and refuses to send to them afterwards.

Where to go next

If you are weighing providers, we publish honest comparisons that say where the other product is better.

Send email that actually arrives

Authentication, suppression and bounce handling built in. Start free, no card required.

Start free
Send Email from Next.js — Route Handlers and Server Actions · Pigeonify