What Server Actions are
Server Actions are functions in your Next.js code that are guaranteed to run on the server, but can be referenced directly from a Client Component as if they were local. You mark a function with "use server", pass it as the action prop on a <form>, and Next.js handles serialization, the network round-trip, and the response.
They became stable in Next.js 14, and in Next.js 15 and 16 they're the recommended pattern for form mutations. They work without JavaScript (the form just POSTs to a Next-managed RPC endpoint), they integrate with React 19's useActionState and useFormStatus for pending states, and they let you keep server logic next to the component that uses it.
Server Actions are not a form backend. They're the wiring; what you do inside them — store, email, notify, integrate — is up to you.
What a form backend is
A form backend is a hosted endpoint that receives form POSTs and turns them into something useful: an email in your inbox, a row in a dashboard, a webhook into Slack, a JSON event into your data warehouse. The whole point is to skip the server-side glue entirely. Your form's action attribute points at https://splitforms.com/api/submit, and the rest is configuration in a dashboard rather than code in your repo.
splitforms (and Formspree, Web3Forms, Getform, Basin, Formspark) all do this same job. The differences are in pricing, feature depth, and how seriously the team takes deliverability and spam.
Server Action example
A "contact us" form using a Server Action and Resend for email:
// 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 audit log
// ❌ 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.
Form backend example
The same form pointed at splitforms:
// app/contact/page.tsx
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 form is a plain HTML form that works without JavaScript and without the Next.js framework being involved at all. Spam filtering, deliverability, retries, dashboard, webhooks — all configured in the splitforms dashboard, none of it shipped in your Next.js bundle.
Side-by-side comparison
| Capability | Server Actions | Form backend (splitforms) |
|---|---|---|
| Time to first working form | 30–90 min | 5 min |
| Works without JS | Yes | Yes |
| App-internal mutations | Native | Via webhook |
| Email to inbox | You wire Resend/Postmark/SES | Built-in, deliverability tuned |
| 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 | Built-in (Pro+) |
| Webhook fan-out + retries | You build it | Built-in, 24h retry |
| Audit log / DPA / retention | You implement | Built-in artifacts |
| Vendor lock-in | None | Low — change form action attribute |
| Cost at 500/mo | ~$2/mo (Resend) | $0 (Free tier) |
| Cost at 5k/mo | ~$15/mo + your time | $10/mo (Pro) |
When Server Actions win
- 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; you want server-side authorization checks before mutating.
- 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.
- You enjoy maintaining your own deliverability. Some teams do. Postmark + DKIM + bounce handling is a fine adventure.
- Strict zero-third-party policy. Some procurement teams refuse any new vendor. Server Actions + your own SMTP is the answer.
When form backends win
- Marketing site contact forms. Static or near-static content, no in-app state to mutate.
- Lead capture across multiple sites. One splitforms account routes to one inbox; no Server Action sprawl.
- You don't want to manage email infrastructure. Inbox rate matters; outsourcing it is rational.
- You need a dashboard for non-engineers. Marketing wants to read submissions without bothering you.
- Multi-recipient routing. Sales submissions to one address, support to another, webhooks to a third — config not code.
- File uploads. Resumes, screenshots, attachments. splitforms handles object-storage and inline-attaches them to the email.
- Compliance artifacts. You need a DPA, you need EU residency, you need retention policies. SaaS providers ship these as features.
Security: what you have to remember
Server Actions look like local function calls but they're HTTP endpoints that anyone on the internet can hit. The Next.js team has documented the attack surface (see the security model in node_modules/next/dist/docs/01-app/02-guides/data-security.md) but the responsibility to apply it is yours. At minimum:
- Validate every input. Use zod, valibot, or yup — never trust
FormData.get()directly. The action runs on the server with whatever bytes the client sent. - Authorize before mutating. Re-check session and permissions inside the action; don't rely on the fact that the page that rendered the form was server-rendered for a logged-in user.
- Rate limit per IP and per session. Server Actions inherit no rate limiting unless you add it.
@upstash/ratelimitis the typical answer. - Add CSRF protection if your action mutates. Next.js sets a same-origin check by default; if you've relaxed it via
experimental.allowedRevalidateHeaderKeysor similar, double-check. - Don't leak server-only data through the response. Anything you return ships to the client.
splitforms handles the equivalent — rate limiting, IP reputation, payload validation against your form schema, abuse detection — server-side, behind a single endpoint. You don't maintain it; we do.
Using both together
The composable pattern: a Server Action handles the in-app side effects (insert into DB, generate a record, kick off a background job), and inside the Server Action you POST to splitforms for the human-facing notifications. Or invert it: the form goes to splitforms, splitforms webhooks back into a Next.js Route Handler that does the app-internal work.
// app/api/lead/route.ts — receives splitforms webhook
import { NextRequest } from "next/server";
import crypto from "node:crypto";
export async function POST(req: NextRequest) {
const raw = await req.text();
const sig = req.headers.get("x-splitforms-signature") ?? "";
// ... HMAC verification ...
const { fields } = JSON.parse(raw);
await db.lead.create({ data: { email: fields.email, message: fields.message } });
await notifySalesRep(fields.email);
return new Response("ok");
}The form is one line of HTML, the deliverability is splitforms' problem, and your app code only runs on confirmed-good submissions because spam filtering already happened upstream.
Tech support and troubleshooting
Five things that bite Next.js teams running their own form Server Actions:
- Server Action emails land in spam — sending domain isn't verified in Resend/Postmark. Add the SPF and DKIM records before going live.
- Form submission silently dropped — the action threw an error after the redirect; useActionState swallows it. Wrap the body in try/catch and surface a real error state.
- Spam wave melted your function quota — Server Actions don't ship spam filtering. Either add hCaptcha + a honeypot, or front the form with splitforms so spam is dropped at the edge.
- File uploads fail at 4MB — Vercel's body size cap. Either presign to S3/R2 from the client, or POST to splitforms which streams to object storage.
- Reply-To not respected — you set From to the visitor's email; Gmail rejects via DMARC. Always send From a domain you own and put the visitor address in Reply-To.
The full webhook spec, retry policy, and signature reference are in the splitforms docs and the API reference; account questions live in the splitforms FAQ.
FAQ
Are Next.js Server Actions production-ready for forms?
Yes. Server Actions stabilized in Next.js 14 and have been the recommended Next pattern for form mutations since Next.js 15. They handle progressive enhancement, work without JavaScript, and integrate cleanly with React 19's useActionState.
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 compliance artifacts (DPA, audit log, retention controls). 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 handles app-internal mutations (creating a record, charging Stripe), then forwards a 'send me an email about this' job to a form backend like splitforms. Best separation of concerns: business logic stays in your app, deliverability stays out of your codebase.
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 useTransition for pending states.
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 — splitforms vs Formspree.