Ch. 24 · Next.js

Next.js Server Actions: Forms, Mutations and Security

How Server Actions turn a function into a POST endpoint in Next.js 15, how to build forms with useActionState, and how to secure every action.

~7 min readintermediateupdated Oct 6, 2026

“What are Server Actions, and how do they work?” is now a standard Next.js interview question, and the senior follow-up is sharper: “This delete button is only shown to admins. Is the action safe?” Candidates who think of an action as “a function the button calls” usually answer yes. The correct answer is no, and explaining why requires knowing what happens on the wire.

Interviewers also use Server Actions to check React 19 knowledge, because forms in the App Router lean on useActionState, useFormStatus and useOptimistic. A complete answer covers the mechanism, a realistic form, error handling and security.

Before you start

You should know Server and Client Components in the App Router, HTML forms and FormData, and basic React hooks. Understanding that a POST request can be sent by any client, not just your UI, is the key security idea. Examples use Next.js 15 with React 19 and the Zod validation library.

The short answer

A Server Action is an async function marked with 'use server' that runs on the server but can be passed to a form’s action prop or called from client code. Next replaces it in the client bundle with a reference ID, and calling it sends a POST to the current page carrying that ID and the arguments. The action can mutate data, call revalidatePath or revalidateTag, and redirect, and the client receives updated UI in the same response. Forms using actions work before JavaScript loads. Because an action is a public HTTP endpoint, it must check authentication, authorisation and input itself.

How it works

Define an action inline in a Server Component or in a file whose first line is 'use server':

// app/counter/page.tsx
import { revalidatePath } from 'next/cache';

let count = 0;

async function increment(formData: FormData) {
  'use server';
  count += Number(formData.get('by') ?? 1);
  revalidatePath('/counter');
}

export default function Page() {
  return (
    <form action={increment}>
      <input name="by" defaultValue="2" />
      <button>Add</button>
      <p>count {count}</p>
    </form>
  );
}
TSX

Look at the HTML that Next.js 15.5 produces for this form and the mechanism becomes concrete:

<form action="" encType="multipart/form-data" method="POST">
  <input type="hidden" name="$ACTION_ID_403a0e15624c9a5783dcf80d2ae73f233607be58c7"/>
  <input name="by" value="2"/>
  <button>Add</button>
</form>
HTML

Before hydration, submitting is an ordinary multipart POST to the same URL, and the hidden field tells Next which action to run. After hydration, React intercepts the submit and sends the request itself with a Next-Action header, then merges the returned RSC payload without a full reload. Either way, the server looks up the action by ID, deserializes the arguments and runs it.

Next.js adds protections around this. Action IDs are unguessable and recomputed for each build, and actions that are never used are removed. Values captured in an inline action’s closure are encrypted before being sent to the client. The framework also compares the Origin header with the host and rejects mismatches, which blocks cross-site form posts. In a test, a request with Origin: http://evil.example was rejected with “Invalid Server Actions request”, while the same request from the app’s own origin succeeded.

Step-by-step walkthrough

Build a comment form that validates input, shows errors and pending state, and refreshes the post.

Step 1: Write the action with checks first

// app/posts/[id]/actions.ts
'use server';
import { z } from 'zod';
import { revalidateTag } from 'next/cache';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';

const Comment = z.object({
  postId: z.string().uuid(),
  body: z.string().trim().min(1, 'Write something').max(500),
});

export type CommentState = { error?: string; ok?: boolean };

export async function addComment(_prev: CommentState, formData: FormData): Promise<CommentState> {
  const session = await getSession();
  if (!session) return { error: 'Please sign in' };

  const parsed = Comment.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { error: parsed.error.issues[0].message };

  await db.comment.create({ data: { ...parsed.data, authorId: session.userId } });
  revalidateTag(`post-${parsed.data.postId}`);
  return { ok: true };
}
TypeScript

The order matters: who is calling, are they allowed, is the input valid, and only then the write. Expected failures are returned as values so the form can show them. Thrown errors are for bugs and go to the nearest error.tsx.

Step 2: Connect the form with useActionState

// app/posts/[id]/CommentForm.tsx
'use client';
import { useActionState } from 'react';
import { addComment, type CommentState } from './actions';

export function CommentForm({ postId }: { postId: string }) {
  const [state, formAction, isPending] = useActionState<CommentState, FormData>(addComment, {});
  return (
    <form action={formAction}>
      <input type="hidden" name="postId" value={postId} />
      <textarea name="body" required />
      <button disabled={isPending}>{isPending ? 'Posting...' : 'Post'}</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}
TSX

useActionState (React 19, replacing useFormState) passes the previous state as the first argument and gives you the latest result and a pending flag. Inside nested components, useFormStatus from react-dom reads the pending state of the enclosing form.

Step 3: Revalidate and redirect correctly

revalidateTag in Step 1 marks the cached post data stale, and because it ran inside an action, the response carries the re-rendered page. To navigate afterwards, call redirect('/posts/' + id) from next/navigation. It works by throwing a special error, so it must not sit inside a try block whose catch swallows it. In a test, an action that called redirect inside try/catch logged caught NEXT_REDIRECT and the browser stayed on the page.

Step 4: Call actions from event handlers when there is no form

Actions are ordinary async functions on the client, so a button can call them directly. Combine them with useOptimistic for instant feedback:

'use client';
import { useOptimistic, startTransition } from 'react';
import { toggleLike } from './actions';

export function LikeButton({ postId, liked }: { postId: string; liked: boolean }) {
  const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked);
  return (
    <button
      onClick={() =>
        startTransition(async () => {
          setOptimisticLiked(!optimisticLiked);
          await toggleLike(postId);
        })
      }
    >
      {optimisticLiked ? 'Unlike' : 'Like'}
    </button>
  );
}
TSX

If the action fails, the optimistic value is discarded when the transition ends and the button shows the real state again.

Worked scenario

A post page renders a DeleteButton only when session.role === 'admin'. The action looks like this:

'use server';
export async function deletePost(formData: FormData) {
  await db.post.delete({ where: { id: String(formData.get('id')) } });
  revalidatePath('/posts');
}
TypeScript

The button is a Client Component that imports deletePost, so the action’s reference ID is compiled into a JavaScript chunk under /_next/static/, a public file that any visitor can download. A curious user finds the ID, sends the same multipart request with curl, and the post is deleted. Hiding the button controlled what was rendered, not who could call the endpoint.

The fix puts the authorisation next to the data:

'use server';
export async function deletePost(formData: FormData) {
  const session = await getSession();
  if (!session || session.role !== 'admin') throw new Error('Forbidden');
  const id = z.string().uuid().parse(formData.get('id'));
  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}
TypeScript

Better still, move the check into a data access function such as deletePostAsAdmin(session, id), so every page, action and Route Handler goes through the same rule.

Common mistake

  • “Only my UI can call the action.” Any HTTP client can send the POST with the action ID.
  • Relying on middleware for protection. Middleware may not match the request, and it usually only checks that a cookie exists.
  • Trusting hidden inputs. A hidden userId field can be edited; read identity from the session.
  • redirect inside try/catch. The redirect error is swallowed. Redirect after the block, or rethrow with unstable_rethrow.
  • Using actions to fetch data. They are POST requests, are not cached, and the client dispatches them one at a time. Read data in Server Components.
  • Large uploads. The default request body limit is 1 MB; raise serverActions.bodySizeLimit deliberately or upload directly to storage.

Verify the behavior

Call the action without a browser to prove it is a public endpoint, and test the origin check.

id=$(curl -s localhost:3000/counter | grep -o 'ACTION_ID_[0-9a-f]*' | head -1)
curl -s -o /dev/null -w '%{http_code}\n' -F "\$${id}=" -F by=5 localhost:3000/counter
# 200: the action ran and the counter is now 5
curl -s -o /dev/null -w '%{http_code}\n' -H 'Origin: http://evil.example' -F "\$${id}=" localhost:3000/counter
# 500: "Invalid Server Actions request" in the server log
Terminal

For automated tests, call the exported action function directly with a FormData object and a mocked session, and assert both the allowed and the forbidden path.

Follow-up questions

When should you use a Route Handler instead? For webhooks, mobile clients, custom status codes, streaming responses or cacheable GET endpoints. Actions are for mutations from your own React UI.

What happens to an action after a new deployment? IDs change per build, so an old tab calling an old ID gets an error. Plan for it during rollouts, for example by forcing a reload on failure.

Why are closure values encrypted? Inline actions can capture variables from the Server Component. Next sends them to the client encrypted, but you should still avoid capturing secrets, because encryption is defence in depth, not access control.

Can an action return data? Yes, any serializable value. It is how useActionState receives errors and results.

Interview exercise

A “transfer credits” form lets a user send credits to another user. The current action reads fromUserId, toUserId and amount from the form. List what is wrong and write the checks the action should perform.

Answer and reasoning

fromUserId must come from the session, never the form, otherwise anyone can spend another user’s credits by editing a hidden field or posting with curl. The action should: get the session and reject anonymous calls; parse toUserId and amount with a schema (integer, positive, below a maximum); reject transfers to oneself; and perform the debit and credit in one database transaction with a balance check, so two parallel submits cannot overdraw. It should return validation failures as state for the form, revalidate the pages showing balances, and log the transfer for audit. The reasoning interviewers want is that the action is an API endpoint: every rule an API would enforce belongs inside it.

Continue learning

Practise more mutation questions in the Next.js interview questions and the Next.js MCQs. Pair this with Next.js caching and revalidation and Next.js middleware for auth. The official references are the Next.js 15 guide to updating data and the data security guide.

More in Next.js

esc