Server actions in Next.js have been stable since 14.0 and they’re the right answer for any form that posts to your own backend. The boilerplate of writing an API route, fetching it from a client component, handling the JSON round-trip, managing the loading state — all of that disappears. The form just submits, the function runs on the server, and Next handles the rest. On the FH client book we’ve migrated every lead form to server actions in the last 18 months, and the only thing we miss is the explicit URL.
The shape of a server action
A server action is a function marked with `'use server'`. It runs on the server, can return a value to the client, and can be passed as the `action` prop on a `<form>` element. That’s the whole API surface. The form submits to it directly — no fetch, no JSON, no error handling for the network layer. The browser handles it as a native form submission, which means it works even if JavaScript hasn’t loaded yet.
// app/contact/actions.ts
"use server";
import { redirect } from "next/navigation";
export async function submitLead(formData: FormData) {
const name = formData.get("name");
const email = formData.get("email");
// … insert into DB …
redirect("/contact/thank-you");
}
// app/contact/page.tsx
import { submitLead } from "./actions";
export default function Contact() {
return (
<form action={submitLead}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Send</button>
</form>
);
}Validation: don’t trust formData
The `FormData` object returns `FormDataEntryValue | null` for every key, which is `string | File | null` in TypeScript terms. That’s not a typed object you can safely insert into a database. We use Zod to parse and validate every server action input — it’s the same library we use on the FH lead pipeline and across every Supabase-backed form on client sites.
import { z } from "zod";
const LeadSchema = z.object({
name: z.string().min(2).max(120),
email: z.string().email(),
phone: z.string().regex(/^[+()0-9 .-]{10,20}$/).optional(),
message: z.string().max(2000).optional(),
});
export async function submitLead(formData: FormData) {
const parsed = LeadSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten() };
}
// parsed.data is fully typed
}Returning state to the client (useFormState)
When the action returns an error, the client component needs to render it. `useFormState` (renamed to `useActionState` in React 19) wires the action’s return value to component state. The form still works without JavaScript — the server returns an HTML page with the error rendered. With JavaScript, the page updates in place.
"use client";
import { useActionState } from "react";
import { submitLead } from "./actions";
export function LeadForm() {
const [state, formAction] = useActionState(submitLead, null);
return (
<form action={formAction}>
<input name="name" />
{state?.errors?.fieldErrors.name?.[0]}
<button>Send</button>
</form>
);
}Talking to Supabase from a server action
Server actions run on the server, so they can talk to Supabase directly using the service-role key. This is the cleanest pattern for inserting leads — no API route, no JWT, no CORS. The service-role key never leaves the server because the action never gets bundled to the client.
"use server";
import "server-only";
import { createClient } from "@supabase/supabase-js";
import { z } from "zod";
const LeadSchema = z.object({ name: z.string(), email: z.string().email() });
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);
export async function submitLead(formData: FormData) {
const parsed = LeadSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { ok: false, errors: parsed.error.flatten() };
await admin.from("submissions").insert({ ...parsed.data, site_id: "fh" });
return { ok: true };
}Rate-limiting server actions
Server actions don’t have built-in rate limiting. For a public lead form this matters — anyone can spam your endpoint with no friction. Two approaches: a Cloudflare Turnstile token validated server-side before insert, or a Redis-backed rate-limit (we use Upstash) keyed off IP. We default to Turnstile because it doesn’t affect conversion rate the way a reCAPTCHA does.
Server actions and revalidation
After a successful mutation, you usually want to revalidate any cached pages that show the affected data. `revalidatePath('/leads')` or `revalidateTag('leads')` does this inline.
import { revalidatePath } from "next/cache";
export async function submitLead(formData: FormData) {
// … insert …
revalidatePath("/admin/leads"); // admin list updates immediately
return { ok: true };
}What server actions don’t do well
Three things. First, they’re not great for third-party API receivers — if Stripe or Twilio is webhook-posting to your app, you still need a regular API route. Second, they’re POST-only and don’t support custom HTTP methods, so anything that needs PUT/DELETE semantics is awkward. Third, debugging is harder than an API route because the action is hidden behind a generated POST endpoint — you’ll see a `POST /` in logs without an obvious indication of which action ran.
Progressive enhancement — the underrated win
A form using a server action works even if JavaScript fails to load. That matters more than you think — slow connections, broken script tags, JavaScript-blocking extensions, ad blockers misfiring. The form just works. Your users on flaky connections (which is most of the Dallas commuter audience on mobile) get a working contact form instead of an inert button.
Pulling it all together
If you’re still writing API routes for every form, you’re writing 3x the code you need. Server actions are stable, well-tooled, and the migration is mechanical. Pair them with Zod validation, Supabase service-role for inserts, Turnstile for spam, and `revalidatePath` for cache invalidation, and you have the entire FH form stack in 60 lines. Schedule a free consultation if you want this run on your site — it’s a clean one-sprint engagement that usually ships in a week.