Overview
When you ship a contact form, you need a reliable way to receive those submissions by email. Google Workspace (formerly G Suite) gives you a professional email address and a trustworthy SMTP relay you can wire up with a few lines of config. This guide covers the full pipeline: generating an App Password on the Google side, storing credentials safely, and wiring up Nodemailer inside a Next.js API route so every submission fires a real email.
The same pattern works for any Node.js server. Swap the API route for an Express endpoint or a serverless function and the rest stays identical.
Part 1: Generate a Gmail App Password (Google side)
Prerequisite: Your Google Workspace account must have 2-Step Verification enabled before App Passwords are available.
Step 1 - Enable 2-Step Verification (skip if already on)
- Go to myaccount.google.com and sign in with the business email (e.g.
contact@yourcompany.com) - Click Security in the left sidebar
- Under "How you sign in to Google", click 2-Step Verification
- Follow the prompts to turn it on
Step 2 - Create an App Password
- Go directly to myaccount.google.com/apppasswords (you may need to re-authenticate)
- In the "App name" text field, type something descriptive like
Contact Form - Click Create
- Google displays a 16-character password (e.g.
abcd efgh ijkl mnop). Copy it now. It will not be shown again. - Click Done
Keep the spaces when you copy the password. Nodemailer handles them correctly and they are part of the credential.
Part 2: Store Credentials as Environment Variables
Never hardcode SMTP credentials in source code. Add them to your .env.local file:
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=contact@yourcompany.com
SMTP_PASS=abcd efgh ijkl mnop
SMTP_TO=you@yourcompany.com
SMTP_USER is the Google Workspace address you generated the App Password for.
SMTP_TO is where you want form submissions delivered (can be the same address or a different one).
Add .env.local to your .gitignore if it is not already there:
echo ".env.local" >> .gitignore
For production (Vercel, Render, Railway, etc.) add the same keys through the platform's environment variable UI. Never commit them.
Part 3: Install Nodemailer
npm install nodemailer
npm install --save-dev @types/nodemailer
Part 4: Create a Reusable Mailer
Create src/lib/mailer.ts:
import nodemailer from "nodemailer";
export const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT ?? 587),
secure: false, // TLS via STARTTLS on port 587
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
secure: false with port 587 uses STARTTLS, which is the correct setting for Gmail SMTP. Only set secure: true if you switch to port 465.
Part 5: Create the API Route
Create src/app/api/contact/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { transporter } from "@/lib/mailer";
export async function POST(req: NextRequest) {
const body = await req.json();
const { name, email, message } = body;
if (!name || !email || !message) {
return NextResponse.json(
{ error: "All fields are required." },
{ status: 400 }
);
}
// Basic email format check
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: "Invalid email address." },
{ status: 400 }
);
}
try {
await transporter.sendMail({
from: `"${name}" <${process.env.SMTP_USER}>`,
replyTo: email,
to: process.env.SMTP_TO,
subject: `New contact form submission from ${name}`,
text: message,
html: `
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message.replace(/\n/g, "<br/>")}</p>
`,
});
return NextResponse.json({ success: true });
} catch (err) {
console.error("SMTP error:", err);
return NextResponse.json(
{ error: "Failed to send email. Please try again." },
{ status: 500 }
);
}
}
A few things worth noting here:
fromusesSMTP_USERas the actual sender address. Gmail requires this to match the authenticated account or the message will be rejected.replyTois set to the visitor's email so you can reply directly to them from your inbox.- The
nameandmessagevalues are interpolated into an HTML body. In a production app that renders untrusted HTML in a browser you would want to sanitize these values, but for email-only output the risk is low. If you render this content anywhere on screen, sanitize it first.
Part 6: Wire Up a Contact Form
Here is a minimal React form component that calls the API route:
"use client";
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("sending");
const form = e.currentTarget;
const data = {
name: (form.elements.namedItem("name") as HTMLInputElement).value,
email: (form.elements.namedItem("email") as HTMLInputElement).value,
message: (form.elements.namedItem("message") as HTMLTextAreaElement).value,
};
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
setStatus(res.ok ? "sent" : "error");
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-lg">
<input name="name" type="text" placeholder="Your name" required />
<input name="email" type="email" placeholder="Your email" required />
<textarea name="message" placeholder="Your message" rows={5} required />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending..." : "Send Message"}
</button>
{status === "sent" && <p>Message sent. We will be in touch soon.</p>}
{status === "error" && <p>Something went wrong. Please try again.</p>}
</form>
);
}
Troubleshooting
"Username and Password not accepted" error The App Password was not copied correctly, or you used your regular account password instead. Go back to myaccount.google.com/apppasswords and generate a fresh one.
"Less secure app access" is missing from settings Google Workspace accounts do not show that toggle. App Passwords are the correct approach for Workspace accounts, not less-secure-app access.
Emails go to spam This usually means your domain lacks proper DNS records. Check that your domain has SPF, DKIM, and DMARC records configured in your DNS provider. Google Workspace provides step-by-step instructions in the Admin Console under Apps > Google Workspace > Gmail > Authenticate email.
Emails send in development but not in production Verify that your environment variables are set in the platform's dashboard and that the deployment was restarted after adding them. Most platforms require a redeploy for new env vars to take effect.
Port 587 is blocked by the hosting provider
Some platforms block outbound port 587. Try port 465 with secure: true instead, or use a transactional email provider (Resend, SendGrid, Postmark) as a drop-in Nodemailer transport.
Summary
| Step | What you did |
|---|---|
| Google side | Enabled 2-Step Verification and generated an App Password |
| Credentials | Stored SMTP settings in .env.local, never in source |
| Mailer | Created a reusable Nodemailer transporter in src/lib/mailer.ts |
| API route | Built a validated POST handler at /api/contact |
| Form | Connected a client component to the API route |
With this setup, every contact form submission travels from your visitor's browser through your Next.js API route, out through Google's SMTP servers, and into your inbox.
