Skip to content

Server Actions, and the Three Things People Get Wrong

A Server Action is a public HTTP endpoint with a generated name. Treating it as a function call is how authorisation gets skipped, validation gets skipped, and the cache never updates.

6 min read

A Server Action looks like a function. You write async function, mark it 'use server', pass it to a form, and it runs on the server. The ergonomics are good enough that people stop thinking about what actually happened.

What actually happened is that Next.js generated an HTTP endpoint, gave it an opaque id, and wired the form to POST to it. Anyone who can read your JavaScript bundle can call it, with any arguments they like, from anywhere.

Every mistake below follows from forgetting that one sentence.

One: treating the caller as trusted

This is the version that ships most often:

'use server';
 
export async function deletePost(id: string) {
  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}

The component that calls it only renders the button for an author, so the reasoning goes, only an author can call it. That is not how it works. The endpoint exists whether or not the button rendered, and id is whatever the caller sends.

Authorise inside the action, every time, against the session rather than against the arguments:

'use server';
 
export async function deletePost(id: string) {
  const session = await auth();
  if (!session) throw new Error('Unauthorised');
 
  // Ownership is checked in the query, not read and then compared - two
  // statements leave a window between the read and the delete.
  const deleted = await db.post.deleteMany({
    where: { id, authorId: session.user.id },
  });
  if (deleted.count === 0) throw new Error('Not found');
 
  revalidatePath('/posts');
}

The rule we apply in review: an action's first statement is the session lookup. If it is anything else, the action is wrong until someone explains why.

Two: validating in the component

Client-side validation is a convenience for the person typing. It is not validation. The action receives FormData straight off the wire and must parse it:

'use server';
 
import { z } from 'zod';
 
const Schema = z.object({
  email: z.string().email(),
  message: z.string().min(20).max(5000),
});
 
export async function submit(prevState: State, formData: FormData) {
  const parsed = Schema.safeParse(Object.fromEntries(formData));
 
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }
 
  await sendToInbox(parsed.data);
  return { ok: true };
}

Returning the errors rather than throwing is what makes the form usable without JavaScript and better with it:

'use client';
 
import { useActionState } from 'react';
 
export function ContactForm() {
  const [state, action, pending] = useActionState(submit, {});
 
  return (
    <form action={action}>
      <input name="email" type="email" required />
      {state.errors?.email ? <p role="alert">{state.errors.email[0]}</p> : null}
 
      <textarea name="message" required />
      {state.errors?.message ? <p role="alert">{state.errors.message[0]}</p> : null}
 
      <button disabled={pending}>{pending ? 'Sending' : 'Send'}</button>
    </form>
  );
}

That form submits before hydration, because it is a real form posting to a real endpoint. Progressive enhancement is not an extra here - it is what you get by not fighting the platform.

Three: mutating without invalidating

The write succeeds, the page still shows the old data, and someone files a caching bug. It is not a caching bug. Nothing told the cache.

revalidatePath('/posts');          // this route's cache, server and client
revalidateTag('posts');            // every fetch tagged 'posts'

Call one of them in the action, after the write. revalidatePath clears the server cache for that route and marks the client Router Cache stale, which is why a mutation in an action needs nothing else and the same mutation in a hand-written route handler needs router.refresh() at the call site. Which cache is which is worth knowing properly: the four layers, and which one bit you.

When not to use one

Actions are for mutations the application owns. They are the wrong tool for:

  • Anything a third party calls. Webhooks need a stable URL and a signature check. That is a Route Handler.
  • Reading data. An action is a POST. Reading in one costs you the cache and gives you nothing; fetch on the server in the component instead.
  • File uploads of any size. Actions go through the server function, with its body limit and its execution time. Presign and upload directly to storage.
  • Anything you want to rate-limit per IP before it reaches your code. Middleware and a Route Handler give you a place to stand.

What we check before an action ships

Four lines, and they catch nearly everything:

  1. Does it start with the session lookup?
  2. Does it parse its input with a schema, and return errors rather than throw?
  3. Does it revalidate what it changed?
  4. Is the ownership check inside the query rather than a read followed by a comparison?

None of that is exotic. It is the same discipline any HTTP endpoint needs - which is exactly the point, because that is what a Server Action is.

Back to all articles