Build a Secure Email Pipeline in Next.js
Learn how to send secure transactional emails using Brevo without exposing your SMTP credentials.

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
I recently shipped a reservations feature that needed to email the customer, alert the admin, and keep our Brevo SMTP key far away from the browser. The form itself was straightforward, but wiring it to send transactional emails securely through Brevo without exposing credentials to the client took a bit of iteration. After a few false starts, this pattern emerged as the most reliable. By the end of this guide you'll have a production-ready server-only pipeline that accepts form submissions, renders both HTML and plaintext templates, and sends dual notifications through Brevo with proper Reply-To, CC, and BCC headers.
1. Capture the submission client-side
Start with a small form that collects whatever information you need. I prefer a controlled component so I can offer optimistic UX later, but the key point is that the browser should POST to a server endpoint you control.
// File: src/components/contact-form.tsx
"use client";
import { FormEvent, useState } from "react";
export function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("sending");
const form = event.currentTarget;
const payload = {
name: form.name.value,
email: form.email.value,
message: form.message.value,
};
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
setStatus(response.ok ? "sent" : "error");
}
return (
<form onSubmit={handleSubmit} className="space-y-3">
<input name="name" required placeholder="Your name" className="border p-2 w-full" />
<input
name="email"
type="email"
required
placeholder="you@example.com"
className="border p-2 w-full"
/>
<textarea
name="message"
required
placeholder="How can we help?"
className="border p-2 w-full"
/>
<button
type="submit"
disabled={status === "sending"}
className="bg-black text-white px-4 py-2"
>
{status === "sending" ? "Sending…" : "Send message"}
</button>
{status === "sent" && <p className="text-green-600">Thanks! Check your inbox.</p>}
{status === "error" && <p className="text-red-600">Something went wrong. Try again.</p>}
</form>
);
}
The form posts JSON to /api/contact. Nothing sensitive leaves the browser yet; the Brevo credentials stay on the server.
2. Configure Brevo in environment variables
Create SMTP credentials in Brevo (Transactional > SMTP & API). Next.js exposes everything listed in .env.local to the server runtime, so add these keys there:
BREVO_SMTP_HOST=smtp-relay.brevo.com
BREVO_SMTP_PORT=587
BREVO_SMTP_LOGIN=your-brevo-smtp-username
BREVO_SMTP_KEY=your-brevo-smtp-password
BREVO_SENDER_NAME="Your Business Name"
BREVO_SENDER_EMAIL=noreply@yourdomain.com
BREVO_ADMIN_EMAIL=admin@yourdomain.com
BREVO_ADMIN_CC=cc@yourdomain.com
BREVO_ADMIN_BCC=bcc@yourdomain.com
Next.js never bundles these values into the client unless variables start with NEXT_PUBLIC_, so your SMTP key is safe.
3. Build a Brevo mailer helper
The helper lives in the app directory, runs only on the server, and centralises Reply-To, CC, and BCC defaults. Under the hood it's just Nodemailer configured for Brevo.
// File: src/lib/brevo.ts
"use server";
import nodemailer from "nodemailer";
interface MailOptions {
subject: string;
to: string;
html: string;
text: string;
fromName?: string;
fromEmail?: string;
replyTo?: string;
cc?: string;
bcc?: string;
}
const transporter = nodemailer.createTransport({
host: process.env.BREVO_SMTP_HOST,
port: Number(process.env.BREVO_SMTP_PORT ?? 587),
secure: false,
auth: {
user: process.env.BREVO_SMTP_LOGIN,
pass: process.env.BREVO_SMTP_KEY,
},
});
export async function sendMail({
subject,
to,
html,
text,
fromName = process.env.BREVO_SENDER_NAME ?? "Your Business",
fromEmail = process.env.BREVO_SENDER_EMAIL ?? "noreply@yourdomain.com",
replyTo = process.env.BREVO_ADMIN_EMAIL ?? "admin@yourdomain.com",
cc = process.env.BREVO_ADMIN_CC,
bcc = process.env.BREVO_ADMIN_BCC,
}: MailOptions) {
if (!process.env.BREVO_SMTP_KEY) {
throw new Error("Brevo SMTP credentials are missing");
}
await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to,
subject,
html,
text,
replyTo,
cc,
bcc,
});
}
I instantiate the transporter once so every send reuses the existing SMTP connection. The function accepts overrides for each address, making it easy to send admin and customer emails with different Reply-To values.
4. Render HTML and text templates
Emails should be legible in plaintext, and this is the right place to embed operational hints like “Replying goes directly to the customer.” Put templates in a helper that returns both variants.
// File: src/utils/email-templates.ts
export function buildAdminTemplate({
name,
email,
message,
}: {
name: string;
email: string;
message: string;
}) {
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.6;">
<h2>New contact form submission</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message.replace(/\n/g, "<br />")}</p>
<p style="color:#555;"><em>Replies to this message will be sent directly to the sender.</em></p>
</div>
`;
const text = [
"New contact form submission",
`Name: ${name}`,
`Email: ${email}`,
"Message:",
message,
"",
"NOTE: Replies to this message will be sent directly to the sender.",
].join("\n");
return { html, text };
}
export function buildCustomerTemplate({
name,
}: {
name: string;
}) {
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.6;">
<h2>Thank you for reaching out</h2>
<p>Hello ${name},</p>
<p>We received your message and will respond as soon as possible.</p>
<p>Best regards,<br/>The team</p>
</div>
`;
const text = [
"Thank you for reaching out",
"",
`Hello ${name},`,
"We received your message and will respond as soon as possible.",
"",
"Best regards,",
"The team",
].join("\n");
return { html, text };
}
Both templates bake in the Reply-To semantics we want the receiver to notice. Nothing fancy—just enough to reduce support questions.
5. Process submissions in a route handler
Route handlers (app/api/.../route.ts) run exclusively on the server, so they’re perfect for proxying requests and keeping SMTP credentials secret. This example parses JSON, performs minimal validation, and sends two emails.
// File: src/app/api/contact/route.ts
import { NextResponse } from "next/server";
import { sendMail } from "@/lib/brevo";
import { buildAdminTemplate, buildCustomerTemplate } from "@/utils/email-templates";
interface ContactPayload {
name: string;
email: string;
message: string;
}
function validate(payload: ContactPayload) {
if (!payload.name?.trim()) return "Name is required";
if (!payload.email?.includes("@")) return "Valid email is required";
if (!payload.message?.trim()) return "Message is required";
return null;
}
export async function POST(request: Request) {
const payload = (await request.json()) as ContactPayload;
const error = validate(payload);
if (error) {
return NextResponse.json({ error }, { status: 400 });
}
const adminTemplate = buildAdminTemplate(payload);
const customerTemplate = buildCustomerTemplate(payload);
await sendMail({
subject: `New message from ${payload.name}`,
to: process.env.BREVO_ADMIN_EMAIL ?? "admin@yourdomain.com",
html: adminTemplate.html,
text: adminTemplate.text,
replyTo: payload.email,
});
await sendMail({
subject: "We received your message",
to: payload.email,
html: customerTemplate.html,
text: customerTemplate.text,
// Reply-To defaults to the admin mailbox so the customer can answer.
});
return NextResponse.json({ success: true });
}
The handler first validates the payload (swap in Zod if you prefer schema-driven validation) and then reuses our templates. The admin notification overrides replyTo so any response jumps straight to the customer. The confirmation email reuses the defaults from the helper, which keeps the conversation in our support inbox.
Wrapping up
We built a secure server-only email pipeline that flows from form submission through Next.js route handlers straight to Brevo. The pattern keeps your SMTP credentials on the server, renders both HTML and plaintext templates to maximize deliverability, and handles Reply-To headers so responses reach the right inbox. From here, you can drop this same sendMail helper into any part of your app—CMS triggers, webhook handlers, scheduled jobs—anywhere you need transactional emails to flow reliably.
Once you have this foundation working, the next step is often building out subscriber capture. If you're looking to grow an email list alongside your transactional notifications, check out how to build a newsletter form with Next.js 15, React Hook Form, and shadcn/ui. That guide pairs perfectly with this one and shows how to sync subscribers directly into Brevo for marketing campaigns.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija