Skip to content

Server Actions for Lead Forms: Replacing Your API Routes Without Losing Sleep

Server actions cut form code in half and ship progressively enhanced HTML. Here’s how to use them without leaking a database query.

John Cravey with AIFounder5 min readUpdated Jul 6, 2026

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.

Free estimate · 2 minutes

Read the playbook. Now see it for your business.

The posts are the mechanism. The estimate below sketches the version we'd actually ship for your business, at your scale. About a minute, no opt-in.

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.

Answers

Frequently asked questions

What does a server action replace?

The API route that existed solely so one form could post to it, plus the fetch call, the JSON handling, and the loading state around it. The action is a function that runs on the server and is called from the form directly, which removes most of the code without removing any of the boundary.

Do I still need to validate input?

Absolutely, and more carefully than in an API route because the ease of the call hides the boundary. The form data arriving at an action is untrusted user input exactly as a request body is. Parse and validate it explicitly before anything touches a database.

How do I return errors to the form?

Through the form-state hook, which gives the action a way to return a serializable result the component can render. Throwing for expected validation failures produces an error boundary rather than a field message, which is the wrong experience for a form that simply needs a corrected phone number.

How do server actions talk to a database?

Directly, on the server, with server-side credentials that never reach the browser. The convenience is exactly where the risk sits: it is easy to write a query with a service-role key in a file that also exports something a client component imports. Keep actions in server-only modules.

Can server actions be rate limited?

They must be, because a form endpoint is a public endpoint regardless of how it is called. The action is reachable by anyone who can read the page. Rate limiting, plus a honeypot field, plus validation, are the minimum for a lead form that will be found by bots within days.

How does revalidation work with actions?

The action can invalidate cached data after a successful write, so the page reflects the change without a full reload. Forgetting it produces the classic complaint that the submission worked but nothing updated, which reads as a bug and is a missing one-line call.

What do server actions do badly?

Anything that is not a mutation triggered by a user. They are not a general data-fetching layer, not a public API, and not a good fit for long-running work. Used as a generic RPC mechanism they recreate the sprawl of the API routes they replaced.

What is the progressive-enhancement benefit?

The form works before JavaScript loads, because it is a real form posting to the server. On a slow connection that is the difference between a lead captured and a lead lost, and it comes for free provided the action is wired to the form rather than to a click handler.

Is a server action secure by default?

The boundary is real, but nothing validates or authorizes for you. Treat every action as a public endpoint: check the input, check the caller is allowed to do this, and never assume the UI's constraints apply, because the UI is not what calls it.

Why do server actions break after a redeploy?

Because the framework mints a new encryption key for action arguments on each build unless one is provided, so a tab left open across a deploy fails on its next submit. Deriving a stable key from an existing secret and pinning it in both build and runtime fixes it permanently.

Should every form become a server action?

Most forms in a Next application, yes. The exceptions are forms that must post to an external system, forms needing complex client-side interaction before submit, and anything a third party posts to, which needs a real endpoint rather than an action.

How do I test a server action?

As a function, because that is what it is: call it with a constructed FormData and assert on the returned state and the side effects. That covers validation and branching. The wiring to the form is worth one integration check, since it is where progressive enhancement quietly breaks.

Question we did not answer? Ask us directly and we will answer it here.

John Cravey, Founder
Written by
John Cravey
Founder

Founder of Frontend Horizon. Writes most of the long-form work on the FH blog.

Newer post
Server-Side Tagging: When SMB Sites Should Pay For It (and When They Shouldn’t)
Older post
Optimizing INP: The Five Patterns That Fix Interaction Latency
Keep reading

More from the blog

Next.js·4 min

Next/Image with Supabase Storage: The Pattern That Saves 70% of Hero Image Bandwidth

Most teams either skip next/image (and ship 4MB heroes) or misconfigure it (and break Coolify deploys). Here’s the pattern that works.

Supabase·4 min

Supabase Auth With Next.js App Router: The Setup We Actually Ship

Most auth tutorials show the wrong pattern. Here’s what actually works in production.

Next.js·10 min

Structured Data in Next.js: How JSON-LD Gets You Cited by Google and AI

Structured data is how you tell Google and AI what your page means, not just what it says. Here’s the Next.js way to ship it.