Modern Web · Nextjs

Next.js 15 Server Actions

How Server Actions actually work under the hood in Next.js 15, form patterns with useActionState and useFormStatus, validation and revalidation, and why every action is a public endpoint you must secure.

John Kihiu12 min read

A Server Action looks like a normal async function you call from a component, but that's a compiler illusion. Under the hood, Next.js compiles it into a POST endpoint with a stable, hashed ID, and the "function call" from your client component is actually a fetch to that endpoint with the arguments serialized in the body. Once that clicks, a lot of the sharper edges of Server Actions — why they need to be idempotent-safe, why they're a public attack surface, why you validate inside them — stop being surprising.

The RPC mechanics

Marking a function 'use server' (either at the top of the file or inline inside another server function) tells the build to extract it into a server-only module reachable via a generated action ID, and to replace the client-side reference with a thin wrapper that POSTs to it. This is why Server Actions work seamlessly with progressive enhancement: a form's action attribute can point directly at a Server Action, and the browser's native form submission handles the POST even with JavaScript disabled — React then hydrates on top of that to give you pending states and optimistic UI when JS is available.

TSX · app/todos/actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

const TodoSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200),
});

export async function createTodo(formData: FormData) {
  const parsed = TodoSchema.safeParse({
    title: formData.get('title'),
  });

  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }

  await db.todo.create({ data: { title: parsed.data.title } });
  revalidatePath('/todos');
  return { error: null };
}

useActionState and useFormStatus for form UX

useActionState (the successor to useFormState) wraps an action and gives you back its current state — validation errors, success messages — along with a wrapped action to pass to the form, updated automatically as submissions resolve. useFormStatus, called from a child component nested inside the form, tells you whether a submission is pending without threading that state down as a prop manually, which is exactly what a reusable submit button needs to disable itself during submission.

TSX · app/todos/todo-form.tsx
'use client';
import { useActionState, useFormStatus } from 'react-dom';
import { createTodo } from './actions';

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Adding…' : 'Add todo'}
    </button>
  );
}

export function TodoForm() {
  const [state, formAction] = useActionState(createTodo, { error: null });

  return (
    <form action={formAction}>
      <input name="title" placeholder="What needs doing?" />
      {state.error?.title && <p>{state.error.title[0]}</p>}
      <SubmitButton />
    </form>
  );
}

Validate inside the action, not just in the form

Client-side validation is a UX nicety; server-side validation inside the action is the actual security boundary, because the action is reachable directly regardless of what the form did. A schema library like Zod parsing FormData at the top of every action — as in the example above — is the pattern that holds up: reject bad input before it touches your database, and return typed field-level errors that useActionState can render back next to the relevant input.

Revalidating after a mutation

A Server Action that writes data doesn't automatically refresh any cached page that displayed the old data — you call revalidatePath('/todos') or revalidateTag('todos') explicitly after the mutation succeeds, which tells Next.js to purge and regenerate that cached route on next visit. Tag-based revalidation is the better default once more than one route displays the same data, since you invalidate by what changed rather than hunting down every path that might render it.

Server Actions are public HTTP endpoints — treat them like one

This is the point worth repeating: a Server Action's generated endpoint can be called directly with curl or any HTTP client, bypassing your form, your client-side checks, and any assumption that "only my UI calls this." Every action that mutates data or returns user-scoped information needs its own authentication and authorization check at the top — the same discipline you'd apply to a REST or GraphQL mutation. Rate limiting matters too: an action with no rate limit sitting behind a public form is a plain invitation for abuse, the same as an unprotected API route would be.

No session check inside the action means no protection at all

A page being behind a login screen doesn't protect the Server Actions its components expose. If deleteAccount() doesn't verify the caller owns the account being deleted, anyone who discovers the action's endpoint can call it for any account ID they can guess.

Wrapping up

Server Actions read like local function calls but behave like RPC endpoints, and treating them that way — validating input with a schema, checking auth inside the action itself, revalidating explicitly after a write — is what keeps that convenience from becoming a liability. useActionState and useFormStatus make the client-side form UX close to free once the server side is solid; get the server side solid first.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.