Use Next.js server actions when form data feeds your own database or app logic; use a hosted form backend when you need submissions stored, emailed, and spam-filtered without building infrastructure. Server actions — stable since Next 14 and unchanged in Next.js 16 — still leave storage, notifications, and spam protection to you; a form endpoint ships all three in one POST URL.
Quick definitions before the comparison. A Server Action is a function in your Next.js code marked "use server" that you pass as a form's action prop; Next.js handles serialization, the network round-trip, and the response. It is wiring — what happens inside (store, email, notify) is code you write. A form backend is a hosted endpoint that receives form POSTs and turns them into an email in your inbox, a row in a dashboard, and a webhook into Slack — configuration in a dashboard rather than code in your repo. splitforms, Formspree, Web3Forms, Getform, and Basin all do this job; the differences are pricing, feature depth, and how seriously the team takes deliverability and spam.
What changed in Next.js 16 for form handling (and what didn't)
Short version: nothing that moves the decision. Server Actions stabilized in Next.js 14. Next.js 15 made them the default mutation primitive and paired them with React 19's useActionState for type-safe action returns, added a post-response hook for after-the-fact work like analytics and webhooks, and tightened caching defaults so form pages stopped serving stale data. Next.js 16 keeps that model intact — the "use server" directive, the action prop, FormDatain, serializable state out. Code you wrote against Next 14's server actions still runs the same way.
What also didn't change — and this is the half people miss — is everything server actions don't do. Across three major versions, a server action still gives you no submission storage, no email delivery, no spam filtering, no dashboard, no file storage, and no rate limiting. It is a well-designed RPC layer. The production work of a contact form starts on the other side of it.
So the question "server actions or a form backend?" isn't version-dependent and isn't religious. It's tied to what the form actually does. The rest of this post walks that decision with code.
When server actions are the right call
- App-internal mutations. The form is "create a comment", "update settings", "invite a teammate" — the data lives in your DB and never needs to leave the app.
- Authenticated forms. You already have the user's session; the action runs with your auth context attached, so server-side authorization checks are one line, not extra plumbing.
- Optimistic UI is important. React 19's
useOptimistic+ Server Actions makes optimistic updates trivial. - You're building a SaaS dashboard, not a contact form. Server Actions are the right primitive for in-app forms.
- Strict zero-third-party policy. Some procurement teams refuse any new vendor. Server Actions + your own SMTP is the answer.
- You enjoy maintaining your own deliverability. Some teams do. Postmark + DKIM + bounce handling is a fine adventure.
The "server actions are enough" checklist
If every statement below is true, skip the form backend — a server action is the whole answer:
- The submission writes to your authoritative database and is part of your app's state — a signup, a settings change, a comment.
- Your deployment has a Node (or Edge) runtime — you are not statically exporting the site.
- The form is behind auth, or you have a real answer for rate limiting and spam on a public endpoint.
- Engineers are the only audience for the data — nobody in marketing or sales needs to browse submissions without asking you.
- You either don't need email notifications, or you already run a verified sending domain through Resend/Postmark/SES.
The moment any one of those flips — the site goes static, the form goes public, a non-engineer asks "can I see the submissions?" — you're building form-backend features by hand.
The security checklist you own
Server Actions look like local function calls but they're HTTP endpoints anyone on the internet can hit. At minimum: validate every input with zod/valibot (never trust FormData.get() directly), re-check session and permissions inside the action, rate limit per IP (@upstash/ratelimitis the typical answer), and don't return server-only data — anything you return ships to the client. A form backend handles the equivalent — rate limiting, IP reputation, payload validation, abuse detection — behind its endpoint, so none of it lives in your repo.
When a form backend wins: static export, spam filtering, email notifications, storage
Static export. Server actions need a runtime. If the site is output: "export" — or lives on GitHub Pages, S3, or any static host — the form has nowhere to POST without an external service. A hosted endpoint also lets a dynamic page go fully static: the component stays a Server Component, the page caches at the CDN, and the browser POSTs directly to splitforms without your host ever seeing the submission.
Spam filtering. A public form on a marketing site gets hit by bots within days. Server actions ship zero spam protection; building a filter that blocks bots without blocking humans is months of tuning. splitforms layers a honeypot, a time-floor check, and an AI classifier on every plan — spam is dropped before it costs you compute or inbox attention.
Email notifications. With a server action, email means wiring Resend/Postmark/SES, verifying a sending domain, adding SPF and DKIM records, and handling bounces. With splitforms, notification emails are built in and free on every plan, including the $0 tier — deliverability is the vendor's job.
Storage.Every submission lands in a dashboard non-engineers can search, filter, and export to CSV — no admin UI to build. File uploads (resumes, screenshots) go to object storage with download links, instead of through your function's request-body limit.
Beyond those four: lead capture across multiple sites routes to one account instead of Server Action sprawl; multi-recipient routing is dashboard config, not code; and webhooks fan submissions into Slack or your own API — each delivery HMAC-signed, sent as a single attempt with an 8-second timeout (no automatic retries, so keep your receiver fast).
The same contact form both ways (side-by-side code)
Version 1: Server Action + Resend
// app/contact/page.tsx
import { contactAction } from "./actions";
import { ContactSubmit } from "./submit";
export default function Page() {
return (
<form action={contactAction}>
<input name="email" type="email" required />
<textarea name="message" required />
<ContactSubmit />
</form>
);
}// app/contact/actions.ts
"use server";
import { Resend } from "resend";
import { redirect } from "next/navigation";
const resend = new Resend(process.env.RESEND_API_KEY!);
export async function contactAction(formData: FormData) {
const email = String(formData.get("email") ?? "");
const message = String(formData.get("message") ?? "");
// ❌ no spam check
// ❌ no rate limit
// ❌ no honeypot
// ❌ no submissions dashboard
// ❌ no retry on Resend failure
await resend.emails.send({
from: "forms@yourdomain.com",
to: "you@yourdomain.com",
subject: "Contact form",
text: `From ${email}\n\n${message}`,
});
redirect("/contact/thanks");
}// app/contact/submit.tsx
"use client";
import { useFormStatus } from "react-dom";
export function ContactSubmit() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Sending..." : "Send"}</button>;
}30 lines, three files, one third-party API key. It works. The TODOs in the comment block are the work of a productionized form backend.
Version 2: form backend
// app/contact/page.tsx
export const dynamic = "force-static"; // page can be cached at the CDN
export default function Page() {
return (
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value={process.env.NEXT_PUBLIC_SPLITFORMS_KEY!} />
<input type="hidden" name="redirect" value="https://yoursite.com/contact/thanks" />
<input name="email" type="email" required />
<textarea name="message" required />
{/* Honeypot — splitforms drops bots automatically */}
<input type="text" name="botcheck" style={{ display: "none" }} />
<button type="submit">Send</button>
</form>
);
}No "use server". No Resend client. No rate-limit middleware. No honeypot validation logic. The component stays a Server Component, the page is fully cacheable, and the form is plain HTML that works without JavaScript and without the Next.js runtime being involved at all. Spam filtering, deliverability, dashboard, webhooks — all configured in the splitforms dashboard, none of it shipped in your bundle. The Next.js form backend page has the dashboard-side walkthrough.
Or both: a server action that forwards to splitforms
When you want a database row and an email notification, a server action that calls splitforms at the end is cleaner than wiring SMTP yourself:
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(10),
});
export async function submitContact(formData: FormData) {
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { ok: false, error: 'Invalid input' as const };
}
// 1. Write to your DB.
const row = await db.insert('contact_submissions').values(parsed.data).returning('*');
// 2. Forward to splitforms for the email + dashboard view.
const body = new URLSearchParams({
access_key: process.env.SPLITFORMS_KEY!,
name: parsed.data.name,
email: parsed.data.email,
message: parsed.data.message,
db_row_id: String(row.id),
});
await fetch('https://splitforms.com/api/submit', {
method: 'POST',
body,
headers: { Accept: 'application/json' },
});
return { ok: true } as const;
}The DB row is the system of record; splitforms is the notification + searchable-history layer. The pattern also inverts: point the form at splitforms and have splitforms webhook back into a Next.js Route Handler for the app-internal work — verify the HMAC signature on the X-Splitforms-Signature header before trusting any field, and remember delivery is a single attempt with an 8-second timeout, so return 200 fast and process asynchronously.
React 19 useActionState patterns and pitfalls
useActionState is the canonical way to wire a server action into a client form. The hook gives you [state, action, isPending], the action's return type flows back to the component, and progressive enhancement works out of the box:
// app/contact/actions.ts
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(10),
});
export type State =
| { status: 'idle' }
| { status: 'success' }
| { status: 'error'; message: string };
export async function submitContact(
_prev: State,
formData: FormData,
): Promise<State> {
const parsed = schema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
});
if (!parsed.success) {
return { status: 'error', message: parsed.error.issues[0]?.message ?? 'Invalid input' };
}
await db.insert('contact_submissions').values({
...parsed.data,
submitted_at: new Date(),
});
return { status: 'success' };
}// app/contact/page.tsx
'use client';
import { useActionState } from 'react';
import { submitContact, type State } from './actions';
const initial: State = { status: 'idle' };
export default function Page() {
const [state, action, pending] = useActionState(submitContact, initial);
return (
<form action={action}>
<label>Name <input name="name" required /></label>
<label>Email <input name="email" type="email" required /></label>
<label>Message <textarea name="message" required /></label>
<button type="submit" disabled={pending}>
{pending ? 'Sending…' : 'Send'}
</button>
{state.status === 'success' && <p>Thanks!</p>}
{state.status === 'error' && <p>{state.message}</p>}
</form>
);
}Note the action's signature changes under useActionState: the first parameter is the previous state, the second is the FormData. Forgetting that and reading formData from the first argument is the most common migration bug.
Pitfalls that bite in production
- Errors silently swallowed. If the action throws after a
redirect(),useActionStatenever surfaces it. Wrap the body in try/catch and return a real error state instead of throwing. - Pending state on the wrong component.
useFormStatusonly works in a component rendered inside the form — a submit button defined in the same component as the form readspending: falseforever. - Emails land in spam. The sending domain isn't verified in Resend/Postmark. Add SPF and DKIM records before going live, and never set From to the visitor's email — Gmail rejects it via DMARC. Put the visitor address in Reply-To.
- Spam wave melts your function quota. Server actions don't ship spam filtering. Add a honeypot + rate limit, or front the form with splitforms so spam is dropped before it reaches your runtime.
- File uploads fail at ~4MB. Vercel's request-body cap. Either presign to S3/R2 from the client, or POST directly to splitforms with Storage connected so the upload never passes through your server action.
Cost and maintenance over 12 months
| Capability | Server Actions | Form backend (splitforms) |
|---|---|---|
| Time to first working form | 30–90 min | 5 min |
| Works without JS | Yes | Yes |
| Works with static export | No | Yes |
| App-internal mutations | Native | Via webhook |
| Email to inbox | You wire Resend/Postmark/SES | Built-in, free on every plan |
| Submissions dashboard | You build it | Built-in |
| Spam filtering | You build it | Layered, on by default |
| Multi-recipient routing | You wire it | Dashboard config |
| File uploads to object storage | You wire S3/R2 | Storage integration |
| Webhook fan-out | You build it | Built-in, HMAC-signed (single attempt, 8s timeout) |
| Vendor lock-in | None | Low — change the form's action attribute |
| Cost at 500/mo | ~$2/mo (Resend) | $0 (Free tier) |
| Cost at 5k/mo | ~$15/mo + your time | $5/mo (Pro) |
The 12-month bill, both columns. 100 submissions/month: effectively $0 either way — well inside Vercel's hobby tier and every provider's free tier. 500/month: a server action costs ~$0–$2/month in compute plus Resend's tier; splitforms is $0/year on the Free plan (500/month included). 1,000/month: splitforms Pro, $5/month — $60/year, with headroom to 5,000. 5,000/month: a server action runs ~$5–$20/month in compute and email (much more if you call an LLM for spam filtering); splitforms Pro is $5/month — $60/year. 15,000/month: the $59 3-Year plan covers it, billed once every 3 years — under $20/year.
The line of code that hides the real cost is resend.emails.send(...). Things Resend does: SMTP delivery, DKIM signing, IP reputation, basic bounce tracking. Things it does not do: spam filtering, per-submitter rate limiting, multi-recipient routing, file uploads, a dashboard for non-engineers, retries with idempotency keys. For a personal site, none of that matters. For a form that captures sales leads, every gap is maintenance you now own — spam clogging the inbox, leads dropped on a transient failure, sales reps asking an engineer for submission history. That recurring time cost, not the compute bill, is what dominates the 12-month comparison.
FAQ
Are Next.js Server Actions production-ready for forms?
Yes. Server Actions stabilized in Next.js 14, became the default mutation primitive in Next.js 15, and Next.js 16 hasn't changed the model. They handle progressive enhancement, work without JavaScript, and integrate cleanly with React 19's useActionState.
Did Next.js 16 change how server actions handle forms?
Not in any way that changes the decision. The form-handling model — mark a function with 'use server', pass it as the form's action prop, read FormData on the server — is the same as it was in Next 14 and 15. What also hasn't changed: server actions still ship no storage, no email delivery, no spam filtering, and no dashboard. That gap is the whole comparison.
When should I use a form backend instead of a Server Action?
When you need any of: deliverable email out of the box, a submissions dashboard, multi-recipient routing, file uploads to object storage, webhook fan-out, spam classification you don't want to maintain, or a form on a statically exported site with no Node runtime. Server Actions get you to a database insert; a form backend gets you to an inbox.
Can I use both — Server Actions and a form backend?
Yes, and many production apps do. The Server Action runs your business logic (write to database, log analytics, kick off a Stripe charge), then forwards the contact data to splitforms at the end so you also get the email + dashboard view. Business logic stays in your app; deliverability stays out of your codebase.
Do server actions replace API routes?
Not entirely. Use server actions for form submissions tied to a specific page (the action lives next to the component that renders the form). Use API routes (app/api/.../route.ts) for endpoints called by external clients, third-party webhooks, or non-form contexts. The two coexist in the same project and the choice is per-endpoint.
Do Server Actions work without JavaScript?
Yes. When you bind a Server Action to a form's `action` prop, Next.js renders it as a regular <form> POST that works without JS. JavaScript progressively enhances with useActionState and useFormStatus for pending states.
What about cold-start latency?
On Vercel's default Node runtime, server actions cold-start in 800–1500ms when the function hasn't been hit recently. Edge runtime cold-starts are 100–250ms. A POST to a hosted form backend is a single warm-pool HTTPS request — typically 80–150ms regardless of how often you submit. For a low-traffic contact form, hosted backend wins on tail latency; for a form on a high-traffic page where the action stays warm, server action latency is fine.
What about security and CSRF?
Server actions have built-in CSRF protection (Next.js validates the origin and includes encrypted action IDs), but input validation, authorization, and rate limiting are still yours to write. External form backends rely on the access key + domain allow-listing for the same purpose, and handle rate limiting and abuse detection server-side. For a marketing-page contact form, the hosted trust model is fine; for a form that mutates your authoritative database, server actions keep the trust boundary inside your own code.
Does using a form backend hurt my Next.js bundle size?
No — splitforms is just a POST endpoint. You don't import a client library, you don't ship a script, you don't add a runtime dependency. The form's action attribute points at our URL and the browser does the rest.
Next steps
- Drop PHP entirely — send HTML form to email without PHP.
- Stand up the inbox path properly — receive form submissions by email.
- Validate before submit — Tailwind CSS form validation.
- Compare hosted backends — Formspree alternative and Web3Forms alternative.
- The dedicated guide for this stack: Next.js form backend. For a React-only project, see handle form submissions in React.
- Need a working starter form? Copy the free contact form template.
Want the deliverability handled for you? Get a free splitforms access key — one action attribute, zero bundle, inbox-ready.