What changed in Next.js 15
Next.js 15 server actions went from "the new thing you should probably try" in Next 14 to the default mutation primitive in Next 15. The major changes that affect form handling: type-safe return values via useActionState in React 19, an explicit unstable_after hook for post-response work (analytics, webhooks), better cookie handling inside actions, and the cache: 'no-store' default that prevented an entire class of stale-data bugs. None of those changes invalidate the case for a hosted form backend; they just make server actions a more pleasant alternative when you need server logic.
The choice between server actions vs API routevs hosted backend isn't a religious one — it's tied to what the form actually does. The rest of this post walks the three patterns side-by-side, with code, and explains which one wins per use case.
Pattern 1: Next.js 15 server action
A server action is a function marked with 'use server' that runs on the Node/Edge runtime when the form submits. It receives FormData directly, runs any logic you write (database writes, validation, side effects), and returns a serializable value the client can render.
// 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' };
}
// Write to your authoritative database.
await db.insert('contact_submissions').values({
...parsed.data,
submitted_at: new Date(),
});
// Optionally: also forward to a hosted backend so you get email + spam filtering.
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>
);
}What you gain.Type safety end-to-end (the action's return type flows back to the component). Built-in CSRF protection. Progressive enhancement (the form submits without JS). Direct access to your database, your auth context, your environment.
What you pay.Every submit costs a server invocation (Vercel Functions or Edge runtime billing). Cold starts on idle endpoints. The page can't be statically exported. You write the spam filter yourself. You wire the email yourself (Nodemailer + SMTP credentials, or Resend, or AWS SES).
Pattern 2: traditional API route
API routes are the older sibling — same runtime as server actions, different file convention. They live at app/api/.../route.ts and you fetch them from a client component:
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/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 POST(req: NextRequest) {
const data = await req.json();
const parsed = schema.safeParse(data);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message }, { status: 400 });
}
await db.insert('contact_submissions').values(parsed.data);
return NextResponse.json({ ok: true });
}API routes are still the right tool for non-formtraffic: webhooks from Stripe, callbacks from auth providers, JSON requests from a mobile client. For forms, server actions are now strictly better — same runtime, less boilerplate, native FormData, automatic CSRF. Use API routes when the caller isn't a form on your own site.
Pattern 3: hosted form backend
The form posts directly from the browser to an external service (splitforms, Formspree, Web3Forms). No server action, no API route, no SMTP credentials. The page can be statically rendered and served from the CDN.
// 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/thanks"
/>
<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">Send</button>
</form>
);
}The component is a Server Component (no 'use client' directive). The form is HTML pointed at an external URL. The browser POSTs directly to splitforms; your Next.js host never sees the submission. /forms/nextjs has the dashboard-side configuration walkthrough.
What you gain. Zero compute on your account. The page is fully cached at the CDN. Spam filtering, email delivery and a dashboard come built-in. Works with static export and any hosting environment. Free for 1,000 submissions/month on splitforms.
What you give up.The submission doesn't touch your database — you have to forward it via webhook if you need a record. The access key sits in the client bundle (it's public, but it's public). Spam filter logic is the vendor's, not yours.
When to use which
The decision tree we'd ship in 2026:
- Form writes to your authoritative DB → server action. Signup forms, settings forms, anything where the submission is part of your app's state.
- Form needs server-side auth context → server action. "Update my profile," "invite a teammate," "leave a comment as logged-in user." The action runs with your session cookie attached, no extra plumbing.
- Form is just lead capture / contact / feedback → hosted backend. The submission needs to land in your inbox, in Slack, or in your CRM. You don't want to build a spam filter. The page should stay cacheable.
- Form runs on a fully static export (no Node runtime) → hosted backend. Server actions don't work without a runtime; the form has nowhere to POST without an external service.
- Form is called by external systems → API route. Webhooks, third-party callbacks, mobile clients. Not a form-submission case at all.
Most apps use all three. The dashboard mutation form? Server action. The Stripe webhook receiver? API route. The marketing-page contact form? splitforms hosted backend. Picking one for everything is a category error — they solve different problems.
Hybrid: server action that forwards to a backend
When you want both 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;
}You skip building a Nodemailer/Resend integration entirely; splitforms emails you on every submission and the dashboard gives you a searchable history. The DB row is the system of record; splitforms is the notification + audit layer.
Cost comparison at small / medium / large volumes
Rough numbers, 2026 pricing:
- 100 submissions/month. Server action: ~$0 (well within Vercel hobby tier). Hosted backend: $0 (free tier on every major provider).
- 1,000 submissions/month. Server action: ~$0–$2 in compute (depends on action duration). Hosted backend: $0 on splitforms (free tier limit), $10/mo on Formspree.
- 10,000 submissions/month. Server action: ~$5–$20 in compute (if the action calls an LLM for spam filtering, much more). Hosted backend: $5/mo on splitforms Pro (5,000) or upgrade as needed; $30+ on Formspree.
- 100,000+ submissions/month. At this volume, you're probably running a custom backend anyway. Hosted backends start to feel restrictive (per-submission billing); a server action plus your own SMTP gateway is more economical.
The free splitforms tier covers 99% of marketing-site contact forms and indie-SaaS lead-capture forms. Pricing details at /pricing.
Try splitforms with your Next.js form
Sign up at /login for a free access key (no credit card), or generate one without signup at /free-contact-form. Drop it into a server component or use it from a server action — the integration is one POST either way. The docs cover webhook configuration, custom redirects, and CC routing.
FAQ
Are Next.js server actions production-ready in 2026?
Yes — server actions have been stable since Next.js 14, and Next.js 15 added type-safe action returns and explicit `unstable_after` cleanup hooks. They're production-ready for write-side mutations: form submissions, mutations to your database, optimistic updates. The only legitimate reason not to use them is if you're statically exporting the site or hosting it somewhere that doesn't support Node runtime.
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.
Why would I use a hosted form backend instead of a server action?
Three reasons. (1) Static export — server actions need a Node runtime. (2) Cost — every server action invocation runs on serverless/Edge billing; a hosted backend is one HTTP request per submission, no compute on your account. (3) Spam filtering — building a real spam filter that blocks bots without blocking legitimate users is months of work; a hosted backend ships with one out of the box. Use a server action when the form writes to your database; use a hosted backend when it just needs to email you.
Can I combine the two?
Yes — and it's a common pattern. The server action runs your business logic (write to database, log analytics, kick off a Stripe charge) and then forwards the contact data to splitforms or another hosted backend at the end so you also get the email + dashboard view. The action returns the success state to the client; the email lands separately.
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?
Server actions have built-in CSRF protection (Next.js validates the origin and includes encrypted action IDs). External form backends rely on the access key + origin whitelist for the same purpose. Both are safe; the difference is who you're trusting. For a marketing-page contact form, the trust model on a hosted backend is fine. For a form that mutates your authoritative database, server actions keep the trust boundary inside your own code.
Do server actions work with React 19's `useActionState` hook?
Yes — that's the canonical way to wire them into a client form in Next.js 15. The hook gives you `[state, action, isPending]` from a server action, and you spread `action` into the form's `action` prop. Progressive enhancement works out of the box: the form still submits if JavaScript is disabled.