How I Handle Contact Forms and Spam in Next.js Without SaaS Fees
Part of Building This Site and Small Business Websites
By Paul Peery · August 28, 2026 · 6 min read

You do not need a $15-a-month form SaaS or an ugly visual CAPTCHA just to let a prospective client send you an email.
Most developers reach for third-party form widgets because handling forms in JavaScript used to mean managing client-side fetch calls, standing up dedicated API routes, orchestrating loading spinners, and fighting off automated bot floods. But inside the Next.js App Router, React Server Actions give you full server execution right beside your UI.
When you pair native Server Actions with Zod validation, a honeypot field, and a lightweight rate limit, you get a secure, free contact pipeline that takes less than an hour to build.
The SaaS tax on simple forms
Third-party form handlers make money on convenience, but they come with trade-offs. You embed an external script or send submissions to an off-site endpoint. In return, you get an extra network dependency, potential layout shifts, and monthly submission caps that force you to upgrade as soon as a random crawler discovers your endpoint.
Even worse, third-party widgets often throw aggressive visual puzzles at your visitors. As I pointed out in my guide on how to write a contact page that actually gets inquiries, every extra hurdle you put between a visitor and your inbox kills your conversion rate. Real humans hate identifying traffic lights and crosswalks.
By keeping the form inside your own Next.js codebase, you stay in control of the styling, the data flow, and your monthly overhead.
How the App Router form pipeline works
A modern App Router form needs only two files: a client form component to display user feedback, and a server action file to process the payload.
Because we want instant feedback and clean disabled states while the message sends, we turn the form itself into a client component while keeping the page wrapper a server component. If you want a refresher on where to draw that line, check out my practical rulebook on Server vs. Client Components.
In React 19 and current Next.js versions, the useActionState hook lets you bind form actions directly to server state:
// app/contact/contact-form.tsx
'use client';
import { useActionState } from 'react';
import { submitContactForm } from './actions';
export function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContactForm, null);
if (state?.success) {
return <p className="text-green-600">Thanks! Your message is on its way.</p>;
}
return (
<form action={formAction} className="space-y-4 max-w-md">
{/* Honeypot field - invisible to humans */}
<div className="hidden" aria-hidden="true">
<label htmlFor="website_url">Leave this empty</label>
<input
type="text"
id="website_url"
name="website_url"
tabIndex={-1}
autoComplete="off"
/>
</div>
{/* Timestamp to catch instant bot submissions */}
<input type="hidden" name="form_loaded_at" value={Date.now()} />
<div>
<label htmlFor="name" className="block text-sm font-medium">Name</label>
<input
id="name"
name="name"
type="text"
required
className="w-full rounded border p-2"
/>
{state?.errors?.name && (
<p className="text-xs text-red-500">{state.errors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input
id="email"
name="email"
type="email"
required
className="w-full rounded border p-2"
/>
{state?.errors?.email && (
<p className="text-xs text-red-500">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium">Message</label>
<textarea
id="message"
name="message"
rows={4}
required
className="w-full rounded border p-2"
/>
{state?.errors?.message && (
<p className="text-xs text-red-500">{state.errors.message[0]}</p>
)}
</div>
{state?.formError && (
<p className="text-sm text-red-600">{state.formError}</p>
)}
<button
type="submit"
disabled={isPending}
className="rounded bg-neutral-900 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
Validating with Zod on the server
Client-side HTML validation (like required and type="email") helps real users avoid typos, but automated scripts skip right past it. Real security happens on the server.
Zod lets you define a strict contract for incoming submissions:
// app/contact/actions.ts
'use server';
import { z } from 'zod';
import { headers } from 'next/headers';
const contactSchema = z.object({
name: z.string().trim().min(2, 'Name must be at least 2 characters').max(100),
email: z.string().trim().email('Please provide a valid email address'),
message: z.string().trim().min(10, 'Message must be at least 10 characters').max(3000),
website_url: z.string().max(0, 'Spam detected'), // Honeypot trap
form_loaded_at: z.coerce.number(),
});
Using safeParse returns structured error fields without crashing your server process, allowing you to pass readable feedback back down to the UI.
Catching 95% of spam with two silent checks
You do not need complex puzzle captchas to stop low-effort scrapers. Most bots crawl forms, grab every <input> tag, fill them with generated links, and fire a POST request within milliseconds.
We break that automation using two silent filters:
- The Honeypot: The
website_urlinput is hidden using CSS (className="hidden") and marked witharia-hidden="true"andtabIndex={-1}so screen readers ignore it. Real humans will never see or focus on it. Simple scraping scripts fill out every input they encounter. Ifwebsite_urlcontains anything, we discard the message. - The Time Delta Check: Humans take at least 3 to 5 seconds to read a label, type their name, enter an email, and compose a sentence. Bots submit within 300 milliseconds. By checking
Date.now() - form_loaded_at < 3000, we catch automated bursts before they hit our mail server.
When a submission fails a bot check, do not return a loud error. Return a fake success message { success: true }. If you tell a bot why it was blocked, the script author will tweak their code. Silently dropping the payload saves your inbox without inviting a smarter retry.
Adding a basic rate limit
If someone writes a custom script aimed specifically at your form, honeypots alone will not stop them. You need to cap how many times an individual IP address can submit within a rolling window.
If you use a managed database like Postgres or Supabase (which I compared in my breakdown of picking a database for solo projects), you can store a quick submission timestamp against a hashed IP. Alternatively, free tiers on services like Upstash Redis let you drop in an sliding-window rate limiter with three lines of code.
Here is how the complete Server Action ties everything together:
// app/contact/actions.ts
export async function submitContactForm(prevState: any, formData: FormData) {
const rawData = Object.fromEntries(formData.entries());
const result = contactSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
formError: 'Please correct the highlighted fields.',
};
}
const { name, email, message, website_url, form_loaded_at } = result.data;
// 1. Silent Honeypot Check
if (website_url && website_url.length > 0) {
return { success: true }; // Pretend it worked
}
// 2. Silent Speed Check (under 3 seconds is almost certainly automated)
const elapsedSeconds = (Date.now() - form_loaded_at) / 1000;
if (elapsedSeconds < 3) {
return { success: true };
}
// 3. Send Notification or Write to Database
try {
await sendNotificationEmail({ name, email, message });
return { success: true };
} catch (err) {
return {
success: false,
formError: 'Something went wrong on our end. Please try again in a minute.',
};
}
}
Once the action verifies the payload, sending the notification via Resend, Postmark, or standard SMTP costs fractions of a cent. Just make sure your DNS records are configured properly—if you haven't yet, read up on getting email delivered with SPF, DKIM, and DMARC so your lead alerts don't land in your spam folder.
The honesty beat: what this approach cannot stop
This setup easily handles standard bot scrapers, SEO sales bots, and random form spammers. It keeps your monthly software bill at zero and creates zero friction for actual customers.
Here is what it will not stop: a determined human hired to paste spam into forms by hand, or an attacker running an automated headless browser with artificial typing delays specifically configured for your site.
If you ever end up in that tier of targeted traffic, putting a Cloudflare Managed Challenge in front of your domain is still far more effective—and cheaper—than tacking on third-party form SaaS subscriptions.
The one-minute implementation checklist
- Use
useActionState: Build your form inside a Client Component so you get instant pending state and field-level validation feedback. - Validate with Zod: Strip whitespace, enforce min/max bounds, and ensure email formatting on the server.
- Add a hidden honeypot: Make an input hidden to humans, verify it stays empty, and silently discard submissions that fill it.
- Check the submission duration: Discard any payload that arrives faster than a human could physically type.
- Deliver directly: Send the cleaned data straight to your database or an email provider API without paying middleman form fees.
Keep reading
All postsComments
No comments yet — be the first!
