All posts

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.

  1. Turn on 2-Step Verification in your Google account.
  2. Create an App Password at myaccount.google.com/apppasswords.
  3. Add it to .env.local and 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:

  • replyTo is 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.

Share this post