3 min readWeb Development
A Free Contact Form in Next.js with Server Actions and Nodemailer
Send contact form messages straight to your Gmail inbox. No paid service, no API route, and no CAPTCHA.
- #nextjs
- #server-actions
- #nodemailer
- #forms
- #typescript
A Free Contact Form in Next.js with Server Actions and Nodemailer
My contact form used to open the visitor's mail app with a mailto: link. If they had no mail app set up, the message was lost.
Form services fix this, but their free plans have low limits. For a personal site you can do it yourself, for free:
- Next.js Server Action to handle the form
- Nodemailer to send the email
- Gmail to deliver it
Step 1: Get a Gmail App Password
Gmail does not allow your normal password here. You need an App Password.
- Turn on 2-Step Verification in your Google account.
- Create an App Password at
myaccount.google.com/apppasswords. - Add it to
.env.localand to your hosting settings:
CONTACT_SMTP_USER=you@gmail.com
CONTACT_SMTP_PASS=your16charpassword
CONTACT_TO_EMAIL=you@gmail.com
Gmail allows about 500 emails a day. That is plenty for a portfolio.
Step 2: Send the email from a Server Action
// app/actions/contact.ts
'use server';
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.CONTACT_SMTP_USER,
pass: process.env.CONTACT_SMTP_PASS,
},
});
export async function sendContactMessage(formData: FormData) {
const name = String(formData.get('name') ?? '').trim();
const email = String(formData.get('email') ?? '').trim();
const message = String(formData.get('message') ?? '').trim();
if (!name || !email.includes('@') || message.length < 10) {
return { status: 'error' };
}
await transporter.sendMail({
from: process.env.CONTACT_SMTP_USER,
to: process.env.CONTACT_TO_EMAIL,
replyTo: email,
subject: `New message from ${name}`,
text: message,
});
return { status: 'success' };
}
Two small things matter:
replyTois the visitor's email. When you press Reply in Gmail, you answer them directly.- Always check the input on the server, even if the browser already checked it.
Step 3: Block spam without a CAPTCHA
Add two hidden fields to the form:
{/* Real people never see this, so they never fill it */}
<input type="text" name="company" tabIndex={-1} className="hidden" />
{/* Set to Date.now() when the form loads */}
<input type="hidden" name="startedAt" ref={startedAtRef} />
Then check them at the top of the action:
const isBot =
formData.get('company') ||
Date.now() - Number(formData.get('startedAt')) < 3000;
if (isBot) return { status: 'success' };
Bots usually fill every field, and they submit in under 3 seconds. Return a fake success, so they don't try again.
Step 4: Call it from the form
In React 19 you can call a Server Action like a normal function:
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const result = await sendContactMessage(new FormData(event.currentTarget));
setStatus(result.status);
}
Show "Sending…" while it waits, then a success or error message.
Why not an API route?
An /api/contact route works too. But a Server Action is simpler: no fetch, no JSON, and no URL to keep in sync. It is also typed from end to end.
That's it
You get a working contact form for free, with no third-party service and no annoying puzzles for your visitors.
Want to see it in action? Send me a message.