Next.js Form Handling with StaticForm (Complete 2026 Guide)
Learn how to add a form backend to your Next.js app with StaticForm. Step-by-step setup, spam protection that blocks 84% of junk, and zero infrastructure.
Next.js Form Handling with StaticForm (Complete 2026 Guide)
Next.js makes building web apps feel easy - until you need a contact form. Then suddenly you’re debating API routes vs Server Actions, figuring out where to store submissions, setting up email transports, and fighting an endless wave of spam bots. All for a form that collects a name and an email.
We built StaticForm because we kept solving this same problem on every client project. The form itself takes 10 minutes. The backend takes a weekend. And three weeks later you’re debugging why Postmark throttled your account because bots discovered your endpoint.
This guide walks through how to use StaticForm as your Next.js form backend - from basic HTML forms to fully validated React components, covering both the App Router and Pages Router.
The Next.js Form Problem
Next.js gives you multiple ways to handle forms, and every single one comes with tradeoffs.
Option 1: API Routes / Route Handlers
You create a POST handler in /api/contact, parse the body, validate fields, send an email via some SMTP service, maybe store it in a database, and return a response. Works great. Until:
- You need an SMTP provider (Postmark, SendGrid, Resend - pick one, configure it, manage API keys)
- You need spam protection (reCAPTCHA? Honeypot? Both?)
- You need to store submissions somewhere (Postgres? Firestore? A JSON file?)
- You need error handling when the email provider goes down
- You need to do this for every project
That’s a lot of infrastructure for a contact form.
Option 2: Server Actions (App Router)
Server Actions are neat. You can handle form submissions right inside your component. But you still need all the same backend stuff - email sending, storage, spam filtering. Server Actions handle the transport, not the destination.
Option 3: Client-side Fetch
Submit via fetch() to some endpoint. Same problems as Option 1, but now you also need CORS headers and client-side state management for loading/error/success states.
The Real Problem
The form UI is the easy part. The hard part is everything that happens after someone clicks “Submit”:
- Where do submissions go? You need persistent storage.
- How do you get notified? Email, Slack, Discord - something.
- How do you stop spam? The average form gets hammered. We see 84% of all submissions across our platform flagged as spam. That’s not a typo - for every 10 submissions, roughly 8 are junk.
- What happens when things break? If your email provider is down, do you lose the submission?
This is what a form backend solves.
Why Use a Form Backend Instead of Building Your Own
Look, you can build all of this yourself. We’re developers, we build things. But here’s the honest math:
Building your own form backend:
- SMTP setup and configuration: 1-2 hours
- Spam filtering (honeypot + reCAPTCHA integration): 2-3 hours
- Database schema and storage: 1-2 hours
- Error handling and retry logic: 1-2 hours
- Dashboard to view submissions: 4-8 hours
- Maintenance per year: 5-10 hours
- Total: 14-27 hours per project
Using a Next.js form backend service like StaticForm:
- Paste an endpoint URL into your form: 5 minutes
- Configure notifications in dashboard: 5 minutes
- Total: 10 minutes
And you get spam protection, notification retries, execution logs, and GDPR-compliant storage in Europe included. No SMTP keys to rotate. No database to maintain.
The tradeoff is real though: you’re adding a dependency. If StaticForm goes down, your form goes down. That’s why we store every submission regardless of whether notifications succeed, and we maintain 99.9% uptime. But it’s a tradeoff you should be aware of.
Setting Up StaticForm for Next.js
Here’s the step-by-step. The whole process takes about 5 minutes.
Step 1: Create Your Form Endpoint
- Sign up at app.staticform.app (no credit card required)
- Click “Create Form”
- Name it something useful (e.g., “Portfolio Contact Form”)
- Copy your form endpoint URL - it looks like this:
https://api.staticform.app/api/v1/forms/019be6a7-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Every form gets 10 free test credits so you can verify everything works before committing.
Step 2: Configure Notifications
In the StaticForm dashboard, set up where you want submissions delivered:
- Email notifications - get an email for every real submission
- Slack integration - pipe submissions into a channel
- Discord webhooks - same idea, different platform
- Custom webhooks - POST the submission data anywhere you want
All notifications run simultaneously. If email fails but Slack succeeds, you still get notified. And every submission is saved in the dashboard regardless.
Step 3: Add the Form to Your Next.js App
Now the fun part. You have a few options depending on your setup.
Basic HTML Form (Works Everywhere)
The simplest approach. Pure HTML, no JavaScript required. This works in any Next.js setup - App Router, Pages Router, static export, whatever.
<form
action="https://api.staticform.app/api/v1/forms/YOUR_FORM_ID"
method="POST"
>
<label for="name">Name</label>
<input type="text" id="name" name="name" required />
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<!-- Honeypot field for spam protection (keep this hidden!) -->
<input type="text" name="_gotcha" style="display:none" />
<button type="submit">Send Message</button>
</form>
When the form submits, StaticForm processes it and redirects the user. You can configure the success and error redirect URLs in your form settings in the dashboard.
That _gotcha field is a honeypot. It’s invisible to real users but bots fill it in automatically. StaticForm uses this as one of several spam signals.
App Router: React Component with Client-Side Submission
For a better UX, you probably want to handle the submission with JavaScript so you can show loading states, inline errors, and a success message without a page redirect.
Here’s a contact form component for the Next.js App Router:
// app/components/ContactForm.tsx
'use client';
import { useState, FormEvent } from 'react';
const FORM_ENDPOINT = 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID';
type FormStatus = 'idle' | 'submitting' | 'success' | 'error';
export default function ContactForm() {
const [status, setStatus] = useState<FormStatus>('idle');
const [errorMessage, setErrorMessage] = useState('');
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('submitting');
setErrorMessage('');
const formData = new FormData(e.currentTarget);
try {
const response = await fetch(FORM_ENDPOINT, {
method: 'POST',
body: formData,
});
if (!response.ok) {
// StaticForm returns ProblemDetails format for validation errors
const problem = await response.json();
throw new Error(problem.detail || 'Something went wrong');
}
setStatus('success');
} catch (err) {
setStatus('error');
setErrorMessage(
err instanceof Error ? err.message : 'Failed to send message'
);
}
}
if (status === 'success') {
return (
<div className="rounded-lg bg-green-50 p-6 text-green-800">
<h3 className="font-semibold">Message sent!</h3>
<p>We'll get back to you as soon as we can.</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">
Name
</label>
<input
type="text"
id="name"
name="name"
required
className="mt-1 block w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
type="email"
id="email"
name="email"
required
className="mt-1 block w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium">
Message
</label>
<textarea
id="message"
name="message"
rows={4}
required
className="mt-1 block w-full rounded border px-3 py-2"
/>
</div>
{/* Honeypot - hidden from real users */}
<input type="text" name="_gotcha" className="hidden" />
{status === 'error' && (
<p className="text-sm text-red-600">{errorMessage}</p>
)}
<button
type="submit"
disabled={status === 'submitting'}
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{status === 'submitting' ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
Then use it in any page:
// app/contact/page.tsx
import ContactForm from '../components/ContactForm';
export default function ContactPage() {
return (
<main className="mx-auto max-w-xl py-12">
<h1 className="mb-8 text-3xl font-bold">Get in Touch</h1>
<ContactForm />
</main>
);
}
Notice: the component has the 'use client' directive because it uses useState and event handlers. The page itself can stay as a Server Component.
Pages Router: Same Concept, Different File
If you’re on the Pages Router, the form component is identical. The only difference is where you put it:
// pages/contact.tsx
import ContactForm from '../components/ContactForm';
export default function ContactPage() {
return (
<main className="mx-auto max-w-xl py-12">
<h1 className="mb-8 text-3xl font-bold">Get in Touch</h1>
<ContactForm />
</main>
);
}
The ContactForm component from above works as-is. No changes needed. That’s one of the nice things about using a form backend - your frontend code doesn’t care which router you’re using because the form just POSTs to an external URL.
Using Server Actions (App Router)
If you prefer Server Actions for progressive enhancement (forms work even without JavaScript), you can combine them with StaticForm:
// app/contact/actions.ts
'use server';
const FORM_ENDPOINT = 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID';
export async function submitContactForm(formData: FormData) {
const response = await fetch(FORM_ENDPOINT, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const problem = await response.json();
return { success: false, error: problem.detail || 'Submission failed' };
}
return { success: true };
}
// app/contact/page.tsx
'use client';
import { useActionState } from 'react';
import { submitContactForm } from './actions';
export default function ContactPage() {
const [state, formAction, isPending] = useActionState(
async (_prev: any, formData: FormData) => {
return await submitContactForm(formData);
},
null
);
return (
<main className="mx-auto max-w-xl py-12">
<h1 className="mb-8 text-3xl font-bold">Get in Touch</h1>
{state?.success && (
<div className="mb-4 rounded bg-green-50 p-4 text-green-800">
Message sent successfully!
</div>
)}
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name">Name</label>
<input type="text" id="name" name="name" required />
</div>
<div>
<label htmlFor="email">Email</label>
<input type="email" id="email" name="email" required />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={4} required />
</div>
<input type="text" name="_gotcha" className="hidden" />
{state?.error && (
<p className="text-sm text-red-600">{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send Message'}
</button>
</form>
</main>
);
}
How StaticForm Spam Protection Works
This is the part we’re most proud of. Spam protection isn’t a single feature - it’s multiple layers working together.
The Layers
-
Honeypot fields - The
_gotchahidden field catches basic bots that fill in every field they find. Simple but surprisingly effective. -
IP reputation - We check the submitter’s IP against known spam sources. Repeat offenders get blocked before their submission is even processed.
-
Content analysis - We analyze the submission content for spam patterns. Link-stuffed messages, known spam phrases, suspicious formatting - it all gets scored.
-
Language detection - If your form is for an English-language site and submissions arrive in a language that doesn’t match, that’s a signal. Not a block on its own, but it factors into the overall spam score.
-
Behavioral signals - How fast was the form filled out? Did it come from a headless browser? These signals help separate real humans from sophisticated bots.
Each submission gets a spam probability score. High-confidence spam gets filtered automatically. Borderline cases are flagged for your review.
The Numbers
Across all forms on our platform, 84% of submissions are spam. That’s the reality of putting a form on the internet in 2026. For every legitimate lead or contact message, there are roughly 5 spam submissions trying to get through.
Here’s why that matters for your wallet: on most form backends, every submission counts against your quota - spam included. If you’re on Formspree’s $10/month plan with 1,000 submissions, and 84% are spam, you’re paying for 840 junk submissions. That’s $8.40/month thrown away on spam.
On StaticForm, spam doesn’t count against your submission limit. You only pay for real submissions. That’s not just a feature - it changes the economics of running forms.
Advanced Features
Webhooks
Need form data in your own system? Set up a webhook and StaticForm will POST the submission data to any URL you specify:
{
"formId": "019be6a7-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"submissionId": "019c1234-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"fields": {
"name": "Jane Smith",
"email": "jane@example.com",
"message": "Hey, I'd like to discuss a project."
},
"metadata": {
"submittedAt": "2026-03-30T14:22:00Z",
"ip": "203.0.113.42",
"userAgent": "Mozilla/5.0..."
}
}
This is great for piping leads into a CRM, triggering automations, or syncing with a database you already run.
Slack Integration
Get real-time notifications in Slack when someone submits your form. Set it up in the dashboard with a Slack webhook URL - submissions show up as nicely formatted messages with all the field data.
Discord Notifications
Same as Slack, but for Discord. Add a Discord webhook URL in your form’s notification settings and every real submission gets posted to your channel.
Execution Logs
When something goes wrong with a notification (email bounced, webhook returned a 500, Slack token expired), StaticForm logs the exact error. No digging through server logs. Open the submission in your dashboard, click the execution log, and see the status code, error message, and timestamp for every notification attempt.
Real-World Example: Portfolio Contact Form with Validation
Let’s put it all together with a production-ready contact form that includes proper validation, accessible markup, and good UX:
// app/components/ContactForm.tsx
'use client';
import { useState, FormEvent } from 'react';
const FORM_ENDPOINT = 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID';
interface FormErrors {
name?: string;
email?: string;
message?: string;
}
export default function ContactForm() {
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [errors, setErrors] = useState<FormErrors>({});
const [serverError, setServerError] = useState('');
function validate(formData: FormData): FormErrors {
const errors: FormErrors = {};
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
if (!name || name.trim().length < 2) {
errors.name = 'Please enter your name (at least 2 characters)';
}
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = 'Please enter a valid email address';
}
if (!message || message.trim().length < 10) {
errors.message = 'Please write at least 10 characters';
}
return errors;
}
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const validationErrors = validate(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setErrors({});
setStatus('submitting');
setServerError('');
try {
const response = await fetch(FORM_ENDPOINT, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const problem = await response.json();
throw new Error(problem.detail || 'Submission failed');
}
setStatus('success');
} catch (err) {
setStatus('error');
setServerError(
err instanceof Error ? err.message : 'Something went wrong. Please try again.'
);
}
}
if (status === 'success') {
return (
<div role="alert" className="rounded-lg bg-green-50 p-6">
<h3 className="text-lg font-semibold text-green-800">Thanks for reaching out!</h3>
<p className="mt-2 text-green-700">
I'll get back to you within 24 hours.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} noValidate className="space-y-5">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name *
</label>
<input
type="text"
id="name"
name="name"
required
aria-describedby={errors.name ? 'name-error' : undefined}
aria-invalid={!!errors.name}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
{errors.name && (
<p id="name-error" className="mt-1 text-sm text-red-600">{errors.name}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email *
</label>
<input
type="email"
id="email"
name="email"
required
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
{errors.email && (
<p id="email-error" className="mt-1 text-sm text-red-600">{errors.email}</p>
)}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
Message *
</label>
<textarea
id="message"
name="message"
rows={5}
required
aria-describedby={errors.message ? 'message-error' : undefined}
aria-invalid={!!errors.message}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
{errors.message && (
<p id="message-error" className="mt-1 text-sm text-red-600">{errors.message}</p>
)}
</div>
{/* Honeypot */}
<div aria-hidden="true" className="absolute -left-[9999px]">
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" />
</div>
{serverError && (
<div role="alert" className="rounded bg-red-50 p-3 text-sm text-red-700">
{serverError}
</div>
)}
<button
type="submit"
disabled={status === 'submitting'}
className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white font-medium
hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500
focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{status === 'submitting' ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
A few things worth noting in this example:
- Client-side validation runs before hitting the API, giving instant feedback
aria-invalidandaria-describedbymake errors accessible to screen readers- The honeypot is positioned off-screen with
aria-hiddenso it’s completely invisible to real users and assistive tech noValidateon the form disables browser-native validation so we control the UX- Error states from the server are handled separately from validation errors
StaticForm vs Other Solutions
There are several Next.js form backend options out there. Here’s a quick honest comparison:
| Feature | StaticForm | Formspree | Getform | Basin |
|---|---|---|---|---|
| Free tier | 10 credits/form | 50/month | 50/month | 100/month |
| Paid from | €4/month | $10/month | $9/month | $19/month |
| Spam counts against quota | No | Yes | Yes | Yes |
| Data storage | EU (GDPR) | US | US | US |
| Execution logs | Yes | No | No | No |
| Notifications | Email, Slack, Discord, Webhooks | Email, Zapier | Email, Slack, Zapier | Email, Webhooks |
The big differentiators for us: spam doesn’t eat your quota, data is stored in Europe (matters if you or your clients are in the EU), and execution logs give you visibility when things break.
Formspree has been around longer and has more integrations through Zapier. If you need a specific third-party integration that StaticForm doesn’t support natively, that’s worth considering. Basin has a generous free tier if you’re just starting out.
We’ve written more detailed breakdowns in our comparison posts if you want the full picture.
For a deeper look at how we compare on pricing, check our pricing page - we’re 17-26% cheaper than competitors across comparable tiers.
When StaticForm Might Not Be the Right Fit
Being honest here:
- If you need complex form logic (multi-step forms, conditional fields, file uploads with processing) - you might need a full form builder like Typeform or a custom solution.
- If you’re already running a backend with a database and email service - adding another dependency might not make sense. Use your existing infrastructure.
- If you need Zapier integrations - we don’t support Zapier (yet). Use webhooks to build your own integrations, or pick a service that has native Zapier support.
StaticForm is purpose-built for the common case: you have a static site or a Next.js app and you need contact forms, lead capture forms, or feedback forms that just work. For that use case, we think it’s the best option.
Getting Started
Here’s the shortest path from zero to a working Next.js form:
- Create a free account at app.staticform.app
- Create a form and copy your endpoint URL
- Grab the code from any of the examples above
- Replace
YOUR_FORM_IDwith your actual endpoint - Submit a test - you get 10 free credits per form, no credit card needed
- Set up notifications - email, Slack, Discord, or webhooks
That’s it. Five minutes, and your Next.js app has a working form with spam protection, notification delivery, and submission storage in Europe.
If your project outgrows the free credits, plans start at €4/month for 500 submissions. And remember - spam doesn’t count, so you’re paying for real submissions only.
Try StaticForm free - start with 10 free credits, no credit card required.